mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
ca7e3b6161
commit
6d2459c7e8
12 changed files with 221 additions and 13 deletions
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema
|
|||
terminalShellIntegrationDisabled: true,
|
||||
terminalShellIntegrationTimeout: true,
|
||||
terminalZshClearEolMark: true,
|
||||
disabledTools: true,
|
||||
})
|
||||
// Add stronger validations for some fields.
|
||||
.merge(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -334,6 +334,7 @@ export type ExtensionState = Pick<
|
|||
| "maxGitStatusFiles"
|
||||
| "requestDelaySeconds"
|
||||
| "showWorktreesInHomeScreen"
|
||||
| "disabledTools"
|
||||
> & {
|
||||
version: string
|
||||
clineMessages: ClineMessage[]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue