feat: add custom denial messages for commands

- Update type definitions to support both string and object format for denied commands
- Add UI for entering custom messages when denying commands
- Use custom messages in ChatView when auto-denying commands
- Add translations for new UI elements

This allows users to provide specific guidance when denying commands,
such as suggesting 'uv run python' when denying direct 'python' commands.

Fixes #8703
This commit is contained in:
Roo Code 2025-10-17 16:01:51 +00:00
parent 3aa9762d04
commit 15f0ecd1fa
6 changed files with 131 additions and 54 deletions

View file

@ -68,7 +68,17 @@ export const globalSettingsSchema = z.object({
followupAutoApproveTimeoutMs: z.number().optional(),
alwaysAllowUpdateTodoList: z.boolean().optional(),
allowedCommands: z.array(z.string()).optional(),
deniedCommands: z.array(z.string()).optional(),
deniedCommands: z
.union([
z.array(z.string()),
z.array(
z.object({
command: z.string(),
message: z.string().optional(),
}),
),
])
.optional(),
commandExecutionTimeout: z.number().optional(),
commandTimeoutAllowlist: z.array(z.string()).optional(),
preventCompletionWithOpenTodos: z.boolean().optional(),

View file

@ -241,7 +241,7 @@ export interface WebviewMessage {
images?: string[]
bool?: boolean
value?: number
commands?: string[]
commands?: string[] | { command: string; message?: string }[]
audioType?: AudioType
serverName?: string
toolName?: string

View file

@ -26,12 +26,7 @@ import { ProfileValidator } from "@roo/ProfileValidator"
import { getLatestTodo } from "@roo/todo"
import { vscode } from "@src/utils/vscode"
import {
getCommandDecision,
CommandDecision,
findLongestPrefixMatch,
parseCommand,
} from "@src/utils/command-validation"
import { getCommandDecision, CommandDecision, parseCommand } from "@src/utils/command-validation"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel"
@ -1028,7 +1023,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const getCommandDecisionForMessage = useCallback(
(message: ClineMessage | undefined): CommandDecision => {
if (message?.type !== "ask") return "ask_user"
return getCommandDecision(message.text || "", allowedCommands || [], deniedCommands || [])
// Convert deniedCommands to string array for getCommandDecision
const deniedCommandsArray =
deniedCommands?.map((cmd) => (typeof cmd === "string" ? cmd : cmd.command)) || []
return getCommandDecision(message.text || "", allowedCommands || [], deniedCommandsArray)
},
[allowedCommands, deniedCommands],
)
@ -1049,17 +1047,32 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
[getCommandDecisionForMessage],
)
// Helper function to get the denied prefix for a command
const getDeniedPrefix = useCallback(
(command: string): string | null => {
// Helper function to get the denied command and its custom message
const getDeniedCommand = useCallback(
(command: string): { prefix: string; message?: string } | null => {
if (!command || !deniedCommands?.length) return null
// Normalize deniedCommands to objects
const normalizedDenied = deniedCommands.map((cmd) => (typeof cmd === "string" ? { command: cmd } : cmd))
// Parse the command into sub-commands and check each one
const subCommands = parseCommand(command)
for (const cmd of subCommands) {
const deniedMatch = findLongestPrefixMatch(cmd, deniedCommands)
if (deniedMatch) {
return deniedMatch
// Find longest matching denied command
let longestMatch: { command: string; message?: string } | null = null
let longestLength = 0
for (const denied of normalizedDenied) {
if (cmd.toLowerCase().startsWith(denied.command.toLowerCase())) {
if (denied.command.length > longestLength) {
longestMatch = denied
longestLength = denied.command.length
}
}
}
if (longestMatch) {
return { prefix: longestMatch.command, message: longestMatch.message }
}
}
return null
@ -1582,11 +1595,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const autoApproveOrReject = async () => {
// Check for auto-reject first (commands that should be denied)
if (lastMessage?.ask === "command" && isDeniedCommand(lastMessage)) {
// Get the denied prefix for the localized message
const deniedPrefix = getDeniedPrefix(lastMessage.text || "")
if (deniedPrefix) {
// Create the localized auto-deny message and send it with the rejection
const autoDenyMessage = tSettings("autoApprove.execute.autoDenied", { prefix: deniedPrefix })
// Get the denied command and its custom message
const deniedCommand = getDeniedCommand(lastMessage.text || "")
if (deniedCommand) {
// Use custom message if provided, otherwise use default localized message
const autoDenyMessage =
deniedCommand.message ||
tSettings("autoApprove.execute.autoDenied", { prefix: deniedCommand.prefix })
vscode.postMessage({
type: "askResponse",
@ -1686,7 +1701,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleSuggestionClickInRow,
isAllowedCommand,
isDeniedCommand,
getDeniedPrefix,
getDeniedCommand,
tSettings,
])

View file

@ -36,7 +36,7 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
allowedCommands?: string[]
allowedMaxRequests?: number | undefined
allowedMaxCost?: number | undefined
deniedCommands?: string[]
deniedCommands?: string[] | { command: string; message?: string }[]
setCachedStateField: SetCachedStateField<
| "alwaysAllowReadOnly"
| "alwaysAllowReadOnlyOutsideWorkspace"
@ -86,6 +86,7 @@ export const AutoApproveSettings = ({
const { t } = useAppTranslation()
const [commandInput, setCommandInput] = useState("")
const [deniedCommandInput, setDeniedCommandInput] = useState("")
const [deniedMessageInput, setDeniedMessageInput] = useState("")
const { autoApprovalEnabled, setAutoApprovalEnabled } = useExtensionState()
const toggles = useAutoApprovalToggles()
@ -106,10 +107,20 @@ export const AutoApproveSettings = ({
const handleAddDeniedCommand = () => {
const currentCommands = deniedCommands ?? []
if (deniedCommandInput && !currentCommands.includes(deniedCommandInput)) {
const newCommands = [...currentCommands, deniedCommandInput]
// Normalize to always work with objects
const normalizedCommands = currentCommands.map((cmd) => (typeof cmd === "string" ? { command: cmd } : cmd))
// Check if command already exists
const exists = normalizedCommands.some((item) => item.command === deniedCommandInput)
if (deniedCommandInput && !exists) {
const newCommand = deniedMessageInput.trim()
? { command: deniedCommandInput, message: deniedMessageInput.trim() }
: { command: deniedCommandInput }
const newCommands = [...normalizedCommands, newCommand]
setCachedStateField("deniedCommands", newCommands)
setDeniedCommandInput("")
setDeniedMessageInput("")
vscode.postMessage({ type: "deniedCommands", commands: newCommands })
}
}
@ -361,45 +372,84 @@ export const AutoApproveSettings = ({
</div>
</div>
<div className="flex gap-2">
<div className="space-y-2">
<div className="flex gap-2">
<Input
value={deniedCommandInput}
onChange={(e: any) => setDeniedCommandInput(e.target.value)}
onKeyDown={(e: any) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleAddDeniedCommand()
}
}}
placeholder={t("settings:autoApprove.execute.deniedCommandPlaceholder")}
className="grow"
data-testid="denied-command-input"
/>
<Button
className="h-8"
onClick={handleAddDeniedCommand}
data-testid="add-denied-command-button">
{t("settings:autoApprove.execute.addButton")}
</Button>
</div>
<Input
value={deniedCommandInput}
onChange={(e: any) => setDeniedCommandInput(e.target.value)}
value={deniedMessageInput}
onChange={(e: any) => setDeniedMessageInput(e.target.value)}
onKeyDown={(e: any) => {
if (e.key === "Enter") {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleAddDeniedCommand()
}
}}
placeholder={t("settings:autoApprove.execute.deniedCommandPlaceholder")}
className="grow"
data-testid="denied-command-input"
placeholder={t("settings:autoApprove.execute.customMessagePlaceholder", {
defaultValue: "Custom message (optional, e.g., 'Use uv run python instead')",
})}
className="w-full text-sm"
data-testid="denied-message-input"
/>
<Button
className="h-8"
onClick={handleAddDeniedCommand}
data-testid="add-denied-command-button">
{t("settings:autoApprove.execute.addButton")}
</Button>
</div>
<div className="flex flex-wrap gap-2">
{(deniedCommands ?? []).map((cmd, index) => (
<Button
key={index}
variant="secondary"
data-testid={`remove-denied-command-${index}`}
onClick={() => {
const newCommands = (deniedCommands ?? []).filter((_, i) => i !== index)
setCachedStateField("deniedCommands", newCommands)
vscode.postMessage({ type: "deniedCommands", commands: newCommands })
}}>
<div className="flex flex-row items-center gap-1">
<div>{cmd}</div>
<X className="text-foreground scale-75" />
<div className="flex flex-col gap-2">
{(deniedCommands ?? []).map((cmd, index) => {
const commandStr = typeof cmd === "string" ? cmd : cmd.command
const messageStr = typeof cmd === "string" ? null : cmd.message
return (
<div
key={index}
className="flex flex-col gap-1 p-2 rounded border border-vscode-panel-border bg-vscode-editor-background">
<div className="flex items-center justify-between">
<code className="text-sm">{commandStr}</code>
<Button
variant="ghost"
size="sm"
data-testid={`remove-denied-command-${index}`}
onClick={() => {
const newCommands = (deniedCommands ?? []).filter(
(_, i) => i !== index,
)
setCachedStateField("deniedCommands", newCommands)
vscode.postMessage({
type: "deniedCommands",
commands: newCommands,
})
}}>
<X className="h-4 w-4" />
</Button>
</div>
{messageStr && (
<div className="text-xs text-vscode-descriptionForeground mt-1">
{t("settings:autoApprove.execute.customMessage", {
defaultValue: "Custom message:",
})}{" "}
{messageStr}
</div>
)}
</div>
</Button>
))}
)
})}
</div>
</div>
)}

View file

@ -74,7 +74,7 @@ export interface ExtensionStateContextType extends ExtensionState {
setShowRooIgnoredFiles: (value: boolean) => void
setShowAnnouncement: (value: boolean) => void
setAllowedCommands: (value: string[]) => void
setDeniedCommands: (value: string[]) => void
setDeniedCommands: (value: string[] | { command: string; message?: string }[]) => void
setAllowedMaxRequests: (value: number | undefined) => void
setAllowedMaxCost: (value: number | undefined) => void
setSoundEnabled: (value: boolean) => void

View file

@ -200,6 +200,8 @@
"deniedCommandsDescription": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.",
"commandPlaceholder": "Enter command prefix (e.g., 'git ')",
"deniedCommandPlaceholder": "Enter command prefix to deny (e.g., 'rm -rf')",
"customMessagePlaceholder": "Custom message (optional, e.g., 'Use uv run python instead')",
"customMessage": "Custom message:",
"addButton": "Add",
"autoDenied": "Commands with the prefix `{{prefix}}` have been forbidden by the user. Do not bypass this restriction by running another command."
},