fix: validate commands against allowed/denied lists before execution

- Add validation in executeCommandTool to check commands against user-configured allowed and denied command lists
- Denied commands take precedence over allowed commands
- Empty command strings in lists are ignored
- Add comprehensive tests for command validation scenarios
- Fixes #5997 where Roo executed commands outside the allowed command set
This commit is contained in:
Roo Code 2025-07-21 07:16:47 +00:00
parent 9fce90be9d
commit 5466af4738
2 changed files with 469 additions and 3 deletions

View file

@ -0,0 +1,401 @@
// npx vitest run src/core/tools/__tests__/executeCommandValidation.spec.ts
import { describe, it, expect, beforeEach, vi } from "vitest"
import { executeCommandTool } from "../executeCommandTool"
import { Task } from "../../task/Task"
import { formatResponse } from "../../prompts/responses"
import type { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../../shared/tools"
// Mock vscode module
vi.mock("vscode", () => ({
workspace: {
getConfiguration: vi.fn().mockReturnValue({
get: vi.fn().mockReturnValue(0), // Default timeout value
}),
},
}))
// Mock the executeCommand function
vi.mock("../executeCommandTool", async () => {
const actual = await vi.importActual("../executeCommandTool")
return {
...actual,
executeCommand: vi.fn().mockResolvedValue([false, "Command executed successfully"]),
}
})
// Mock formatResponse
vi.mock("../../prompts/responses", () => ({
formatResponse: {
toolError: vi.fn((msg: string) => msg),
rooIgnoreError: vi.fn((msg: string) => msg),
toolResult: vi.fn((msg: string) => msg),
},
}))
describe("executeCommandTool - Command Validation", () => {
let mockCline: any
let mockAskApproval: AskApproval
let mockHandleError: HandleError
let mockPushToolResult: PushToolResult
let mockRemoveClosingTag: RemoveClosingTag
let block: ToolUse
let mockProvider: any
beforeEach(() => {
vi.clearAllMocks()
// Create a mock provider with getState method
mockProvider = {
getState: vi.fn().mockResolvedValue({
allowedCommands: [],
deniedCommands: [],
alwaysAllowExecute: false,
}),
postMessageToWebview: vi.fn(),
}
// Create mock cline with provider
mockCline = {
consecutiveMistakeCount: 0,
didRejectTool: false,
recordToolError: vi.fn(),
say: vi.fn(),
sayAndCreateMissingParamError: vi.fn().mockResolvedValue("Missing parameter error"),
ask: vi.fn(),
rooIgnoreController: {
validateCommand: vi.fn().mockReturnValue(null),
},
providerRef: {
deref: vi.fn().mockResolvedValue(mockProvider),
},
cwd: "/test/workspace",
lastMessageTs: Date.now(),
terminalProcess: undefined,
}
mockAskApproval = vi.fn().mockResolvedValue(true)
mockHandleError = vi.fn()
mockPushToolResult = vi.fn()
mockRemoveClosingTag = vi.fn((tag, text) => text || "")
block = {
type: "tool_use",
name: "execute_command",
params: {
command: "echo test",
},
partial: false,
}
})
describe("Allowed Commands Validation", () => {
it("should allow command when it matches allowed command exactly", async () => {
// Setup allowed commands
mockProvider.getState.mockResolvedValue({
allowedCommands: ["echo", "ls", "pwd"],
deniedCommands: [],
alwaysAllowExecute: false,
})
block.params.command = "echo hello world"
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockCline.say).not.toHaveBeenCalledWith("error", expect.any(String))
expect(mockAskApproval).toHaveBeenCalledWith("command", "echo hello world")
})
it("should allow command when it starts with allowed command prefix", async () => {
mockProvider.getState.mockResolvedValue({
allowedCommands: ["npm install", "npm test"],
deniedCommands: [],
alwaysAllowExecute: false,
})
block.params.command = "npm install express"
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockCline.say).not.toHaveBeenCalledWith("error", expect.any(String))
expect(mockAskApproval).toHaveBeenCalledWith("command", "npm install express")
})
it("should reject command not in allowed list", async () => {
mockProvider.getState.mockResolvedValue({
allowedCommands: ["echo", "ls"],
deniedCommands: [],
alwaysAllowExecute: false,
})
block.params.command = "rm -rf /"
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockCline.say).toHaveBeenCalledWith(
"error",
"Command 'rm' is not in the allowed commands list. Add it to the allowed commands in settings to execute it.",
)
expect(mockPushToolResult).toHaveBeenCalledWith("Command 'rm' is not allowed by user configuration")
expect(mockAskApproval).not.toHaveBeenCalled()
})
it("should allow any command when allowed list is empty", async () => {
mockProvider.getState.mockResolvedValue({
allowedCommands: [],
deniedCommands: [],
alwaysAllowExecute: false,
})
block.params.command = "any-command --with-args"
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockCline.say).not.toHaveBeenCalledWith("error", expect.any(String))
expect(mockAskApproval).toHaveBeenCalledWith("command", "any-command --with-args")
})
})
describe("Denied Commands Validation", () => {
it("should reject command in denied list", async () => {
mockProvider.getState.mockResolvedValue({
allowedCommands: [],
deniedCommands: ["rm", "sudo", "chmod"],
alwaysAllowExecute: false,
})
block.params.command = "rm -rf /tmp/test"
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockCline.say).toHaveBeenCalledWith(
"error",
"Command 'rm' is in the denied commands list and cannot be executed.",
)
expect(mockPushToolResult).toHaveBeenCalledWith("Command 'rm' is denied by user configuration")
expect(mockAskApproval).not.toHaveBeenCalled()
})
it("should reject command that starts with denied prefix", async () => {
mockProvider.getState.mockResolvedValue({
allowedCommands: [],
deniedCommands: ["sudo apt-get"],
alwaysAllowExecute: false,
})
block.params.command = "sudo apt-get update"
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockCline.say).toHaveBeenCalledWith(
"error",
"Command 'sudo' is in the denied commands list and cannot be executed.",
)
expect(mockPushToolResult).toHaveBeenCalledWith("Command 'sudo' is denied by user configuration")
expect(mockAskApproval).not.toHaveBeenCalled()
})
it("should prioritize denied list over allowed list", async () => {
mockProvider.getState.mockResolvedValue({
allowedCommands: ["git"],
deniedCommands: ["git push --force"],
alwaysAllowExecute: false,
})
block.params.command = "git push --force origin main"
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockCline.say).toHaveBeenCalledWith(
"error",
"Command 'git' is in the denied commands list and cannot be executed.",
)
expect(mockPushToolResult).toHaveBeenCalledWith("Command 'git' is denied by user configuration")
expect(mockAskApproval).not.toHaveBeenCalled()
})
})
describe("Always Allow Execute", () => {
it("should skip validation when alwaysAllowExecute is true", async () => {
mockProvider.getState.mockResolvedValue({
allowedCommands: ["echo"],
deniedCommands: ["rm"],
alwaysAllowExecute: true,
})
block.params.command = "rm -rf /tmp/test"
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockCline.say).not.toHaveBeenCalledWith("error", expect.any(String))
expect(mockAskApproval).toHaveBeenCalledWith("command", "rm -rf /tmp/test")
})
})
describe("Edge Cases", () => {
it("should handle commands with leading/trailing whitespace", async () => {
mockProvider.getState.mockResolvedValue({
allowedCommands: [" echo ", "ls"],
deniedCommands: [],
alwaysAllowExecute: false,
})
block.params.command = " echo test "
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockCline.say).not.toHaveBeenCalledWith("error", expect.any(String))
expect(mockAskApproval).toHaveBeenCalled()
})
it("should handle empty command strings in lists", async () => {
mockProvider.getState.mockResolvedValue({
allowedCommands: ["echo", "", " ", "ls"],
deniedCommands: ["", " "],
alwaysAllowExecute: false,
})
block.params.command = "echo test"
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockCline.say).not.toHaveBeenCalledWith("error", expect.any(String))
expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test")
})
it("should handle when provider is not available", async () => {
mockCline.providerRef.deref.mockResolvedValue(null)
block.params.command = "echo test"
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
// Should proceed without validation when provider is not available
expect(mockCline.say).not.toHaveBeenCalledWith("error", expect.any(String))
expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test")
})
})
describe("Complex Command Scenarios", () => {
it("should validate piped commands by first command", async () => {
mockProvider.getState.mockResolvedValue({
allowedCommands: ["ls", "grep"],
deniedCommands: [],
alwaysAllowExecute: false,
})
block.params.command = "ls -la | grep test"
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockCline.say).not.toHaveBeenCalledWith("error", expect.any(String))
expect(mockAskApproval).toHaveBeenCalledWith("command", "ls -la | grep test")
})
it("should validate chained commands by first command", async () => {
mockProvider.getState.mockResolvedValue({
allowedCommands: ["cd"],
deniedCommands: [],
alwaysAllowExecute: false,
})
block.params.command = "cd /tmp && ls -la"
await executeCommandTool(
mockCline as unknown as Task,
block,
mockAskApproval,
mockHandleError,
mockPushToolResult,
mockRemoveClosingTag,
)
expect(mockCline.say).not.toHaveBeenCalledWith("error", expect.any(String))
expect(mockAskApproval).toHaveBeenCalledWith("command", "cd /tmp && ls -la")
})
})
})

View file

@ -51,6 +51,68 @@ export async function executeCommandTool(
return
}
// Validate command against allowed/denied lists
const clineProvider = await cline.providerRef.deref()
if (clineProvider) {
const state = await clineProvider.getState()
const { allowedCommands, deniedCommands, alwaysAllowExecute } = state
// Skip validation if alwaysAllowExecute is true
if (!alwaysAllowExecute) {
// Extract the base command (first word) for validation
const baseCommand = command.trim().split(/\s+/)[0]
// Check denied commands first (takes precedence)
if (deniedCommands && deniedCommands.length > 0) {
const isDenied = deniedCommands.some((deniedCmd) => {
const trimmedDenied = deniedCmd.trim()
// Skip empty strings
if (!trimmedDenied) return false
const deniedBase = trimmedDenied.split(/\s+/)[0]
return baseCommand === deniedBase || command!.startsWith(trimmedDenied)
})
if (isDenied) {
await cline.say(
"error",
`Command '${baseCommand}' is in the denied commands list and cannot be executed.`,
)
pushToolResult(
formatResponse.toolError(`Command '${baseCommand}' is denied by user configuration`),
)
return
}
}
// Check allowed commands if list exists
if (allowedCommands && allowedCommands.length > 0) {
// Filter out empty strings and check if any non-empty commands exist
const nonEmptyAllowedCommands = allowedCommands.filter((cmd) => cmd.trim())
if (nonEmptyAllowedCommands.length > 0) {
const isAllowed = nonEmptyAllowedCommands.some((allowedCmd) => {
const trimmedAllowed = allowedCmd.trim()
const allowedBase = trimmedAllowed.split(/\s+/)[0]
return baseCommand === allowedBase || command!.startsWith(trimmedAllowed)
})
if (!isAllowed) {
await cline.say(
"error",
`Command '${baseCommand}' is not in the allowed commands list. Add it to the allowed commands in settings to execute it.`,
)
pushToolResult(
formatResponse.toolError(
`Command '${baseCommand}' is not allowed by user configuration`,
),
)
return
}
}
}
}
}
cline.consecutiveMistakeCount = 0
command = unescapeHtmlEntities(command) // Unescape HTML entities.
@ -61,8 +123,8 @@ export async function executeCommandTool(
}
const executionId = cline.lastMessageTs?.toString() ?? Date.now().toString()
const clineProvider = await cline.providerRef.deref()
const clineProviderState = await clineProvider?.getState()
const clineProviderForExecution = await cline.providerRef.deref()
const clineProviderState = await clineProviderForExecution?.getState()
const {
terminalOutputLineLimit = 500,
terminalOutputCharacterLimit = DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT,
@ -105,7 +167,10 @@ export async function executeCommandTool(
pushToolResult(result)
} catch (error: unknown) {
const status: CommandExecutionStatus = { executionId, status: "fallback" }
clineProvider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
clineProviderForExecution?.postMessageToWebview({
type: "commandExecutionStatus",
text: JSON.stringify(status),
})
await cline.say("shell_integration_warning")
if (error instanceof ShellIntegrationError) {