diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index a56a00fc355a..59541b62fbaf 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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(), diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0f888e975a92..6af397a7ccdc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1676,8 +1676,21 @@ export class ClineProvider * Merges denied commands from global state and workspace configuration * with proper validation and deduplication */ - private mergeDeniedCommands(globalStateCommands?: string[]): string[] { - return this.mergeCommandLists("deniedCommands", "denied", globalStateCommands) + private mergeDeniedCommands( + globalStateCommands?: string[] | { command: string; message?: string }[], + ): { command: string; message?: string }[] { + // Handle both string[] and object[] formats + if (!globalStateCommands) { + return [] + } + + // If it's already in the new format, return as-is + if (globalStateCommands.length > 0 && typeof globalStateCommands[0] === "object") { + return globalStateCommands as { command: string; message?: string }[] + } + + // If it's in the old string format, convert to new format for compatibility + return (globalStateCommands as string[]).map((cmd) => ({ command: cmd })) } /** diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5edd2fd7f2fa..530ba82d9e21 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1020,18 +1020,34 @@ export const webviewMessageHandler = async ( break } case "deniedCommands": { - // Validate and sanitize the commands array + // Validate and sanitize the commands array - now supports both strings and objects const commands = message.commands ?? [] - const validCommands = Array.isArray(commands) - ? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) - : [] - await updateGlobalState("deniedCommands", validCommands) + // Normalize to object format for consistency + const normalizedCommands: { command: string; message?: string }[] = [] + + if (Array.isArray(commands)) { + for (const cmd of commands) { + if (typeof cmd === "string" && cmd.trim().length > 0) { + normalizedCommands.push({ command: cmd.trim() }) + } else if (typeof cmd === "object" && cmd !== null && "command" in cmd) { + const cmdObj = cmd as { command: string; message?: string } + if (typeof cmdObj.command === "string" && cmdObj.command.trim().length > 0) { + normalizedCommands.push({ + command: cmdObj.command.trim(), + ...(cmdObj.message ? { message: cmdObj.message } : {}), + }) + } + } + } + } + + await updateGlobalState("deniedCommands", normalizedCommands) // Also update workspace settings. await vscode.workspace .getConfiguration(Package.name) - .update("deniedCommands", validCommands, vscode.ConfigurationTarget.Global) + .update("deniedCommands", normalizedCommands, vscode.ConfigurationTarget.Global) break } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 21a1c1c5ea4f..321fd617fdfe 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -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 diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 0248abb6eed3..b101107530c2 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -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 { 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 { + // 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 { // 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 & { 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 = ({ -
+
+
+ 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" + /> + +
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" /> -
-
- {(deniedCommands ?? []).map((cmd, index) => ( - +
+ {messageStr && ( +
+ {t("settings:autoApprove.execute.customMessage", { + defaultValue: "Custom message:", + })}{" "} + {messageStr} +
+ )}
- - ))} + ) + })} )} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index e680014fa20f..07479bf692d3 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -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 diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index dfccc49cc4ce..2b51e2d518f2 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -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." },