diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index f5e9fc32bd..52341cd49f 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -29,6 +29,20 @@ export const DEFAULT_WRITE_DELAY_MS = 1000 */ export const DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT = 50_000 +/** + * DeniedCommand type - can be either a string (for backward compatibility) + * or an object with prefix and optional custom message + */ +export const deniedCommandSchema = z.union([ + z.string(), + z.object({ + prefix: z.string(), + message: z.string().optional(), + }), +]) + +export type DeniedCommand = z.infer + /** * GlobalSettings */ @@ -63,7 +77,7 @@ 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.array(deniedCommandSchema).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 274060a19b..2a0d6a755e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1498,8 +1498,62 @@ 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 | { prefix: string; message?: string })[], + ): (string | { prefix: string; message?: string })[] { + try { + // Validate and sanitize global state commands + const validGlobalCommands = Array.isArray(globalStateCommands) + ? globalStateCommands.filter((cmd) => { + if (typeof cmd === "string") { + return cmd.trim().length > 0 + } else if (typeof cmd === "object" && cmd !== null && "prefix" in cmd) { + return typeof cmd.prefix === "string" && cmd.prefix.trim().length > 0 + } + return false + }) + : [] + + // Get workspace configuration commands + const workspaceCommands = + vscode.workspace + .getConfiguration(Package.name) + .get<(string | { prefix: string; message?: string })[]>("deniedCommands") || [] + + // Validate and sanitize workspace commands + const validWorkspaceCommands = Array.isArray(workspaceCommands) + ? workspaceCommands.filter((cmd) => { + if (typeof cmd === "string") { + return cmd.trim().length > 0 + } else if (typeof cmd === "object" && cmd !== null && "prefix" in cmd) { + return typeof cmd.prefix === "string" && cmd.prefix.trim().length > 0 + } + return false + }) + : [] + + // Combine and deduplicate commands + // Global state takes precedence over workspace configuration + const prefixMap = new Map() + + // Add workspace commands first + validWorkspaceCommands.forEach((cmd) => { + const prefix = typeof cmd === "string" ? cmd : cmd.prefix + prefixMap.set(prefix, cmd) + }) + + // Add global commands (overwriting workspace if same prefix) + validGlobalCommands.forEach((cmd) => { + const prefix = typeof cmd === "string" ? cmd : cmd.prefix + prefixMap.set(prefix, cmd) + }) + + return Array.from(prefixMap.values()) + } catch (error) { + console.error(`Error merging denied commands:`, error) + // Return empty array as fallback to prevent crashes + return [] + } } /** diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index f5dc6a467f..9ffa85fd4e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -12,6 +12,7 @@ import { type GlobalState, type ClineMessage, TelemetryEventName, + type DeniedCommand, } from "@roo-code/types" import { CloudService } from "@roo-code/cloud" import { TelemetryService } from "@roo-code/telemetry" @@ -751,7 +752,7 @@ export const webviewMessageHandler = async ( ? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) : [] - await updateGlobalState("allowedCommands", validCommands) + await updateGlobalState("allowedCommands", validCommands as string[]) // Also update workspace settings. await vscode.workspace @@ -761,10 +762,18 @@ export const webviewMessageHandler = async ( break } case "deniedCommands": { - // Validate and sanitize the commands array + // Validate and sanitize the commands array - now supports both string and object format const commands = message.commands ?? [] const validCommands = Array.isArray(commands) - ? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0) + ? commands.filter((cmd): cmd is string | DeniedCommand => { + if (typeof cmd === "string") { + return cmd.trim().length > 0 + } else if (typeof cmd === "object" && cmd !== null && "prefix" in cmd) { + // Validate object format + return typeof cmd.prefix === "string" && cmd.prefix.trim().length > 0 + } + return false + }) : [] await updateGlobalState("deniedCommands", validCommands) @@ -772,7 +781,7 @@ export const webviewMessageHandler = async ( // Also update workspace settings. await vscode.workspace .getConfiguration(Package.name) - .update("deniedCommands", validCommands, vscode.ConfigurationTarget.Global) + .update("deniedCommands", validCommands as any, vscode.ConfigurationTarget.Global) break } diff --git a/src/package.json b/src/package.json index dab031404d..b7d1dd41bb 100644 --- a/src/package.json +++ b/src/package.json @@ -333,7 +333,28 @@ "roo-cline.deniedCommands": { "type": "array", "items": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "prefix": { + "type": "string", + "description": "The command prefix to deny" + }, + "message": { + "type": "string", + "description": "Custom message to show when this command is denied" + } + }, + "required": [ + "prefix" + ], + "additionalProperties": false + } + ] }, "default": [], "description": "%commands.deniedCommands.description%" diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index cb8759d851..33fa6cad36 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -7,6 +7,7 @@ import type { InstallMarketplaceItemOptions, MarketplaceItem, ShareVisibility, + DeniedCommand, } from "@roo-code/types" import { marketplaceItemSchema } from "@roo-code/types" @@ -221,7 +222,7 @@ export interface WebviewMessage { images?: string[] bool?: boolean value?: number - commands?: string[] + commands?: string[] | DeniedCommand[] 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 e73ac67701..c0556e98d7 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -27,7 +27,7 @@ import { vscode } from "@src/utils/vscode" import { getCommandDecision, CommandDecision, - findLongestPrefixMatch, + findLongestDeniedMatch, parseCommand, } from "@src/utils/command-validation" import { useTranslation } from "react-i18next" @@ -1077,17 +1077,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + // Helper function to get the denied command info (prefix and optional message) for a command + const getDeniedCommandInfo = useCallback( + (command: string): { prefix: string; message?: string } | null => { if (!command || !deniedCommands?.length) return null // 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 + const deniedMatch = findLongestDeniedMatch(cmd, deniedCommands) + if (deniedMatch.prefix) { + return deniedMatch as { prefix: string; message?: string } } } return null @@ -1606,11 +1606,12 @@ 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 info (prefix and optional custom message) + const deniedInfo = getDeniedCommandInfo(lastMessage.text || "") + if (deniedInfo) { + // Use custom message if provided, otherwise use the default localized message + const autoDenyMessage = + deniedInfo.message || tSettings("autoApprove.execute.autoDenied", { prefix: deniedInfo.prefix }) vscode.postMessage({ type: "askResponse", @@ -1710,7 +1711,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { const isAllowed = allowedCommands.includes(pattern) const newAllowed = isAllowed ? allowedCommands.filter((p) => p !== pattern) : [...allowedCommands, pattern] - const newDenied = deniedCommands.filter((p) => p !== pattern) + const newDenied = deniedCommands.filter((cmd) => { + const prefix = typeof cmd === "string" ? cmd : cmd.prefix + return prefix !== pattern + }) setAllowedCommands(newAllowed) setDeniedCommands(newDenied) @@ -91,8 +94,14 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec } const handleDenyPatternChange = (pattern: string) => { - const isDenied = deniedCommands.includes(pattern) - const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern] + const deniedPrefixes = deniedCommandsToPrefixes(deniedCommands) + const isDenied = deniedPrefixes.includes(pattern) + const newDenied = isDenied + ? deniedCommands.filter((cmd) => { + const prefix = typeof cmd === "string" ? cmd : cmd.prefix + return prefix !== pattern + }) + : [...deniedCommands, pattern] const newAllowed = allowedCommands.filter((p) => p !== pattern) setAllowedCommands(newAllowed) @@ -194,7 +203,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 5ce44c747d..e7eddcdcc7 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -5,6 +5,7 @@ import { useAppTranslation } from "@/i18n/TranslationContext" import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" import { vscode } from "@/utils/vscode" import { Button, Input, Slider, StandardTooltip } from "@/components/ui" +import type { DeniedCommand } from "@roo-code/types" import { SetCachedStateField } from "./types" import { SectionHeader } from "./SectionHeader" @@ -34,7 +35,7 @@ type AutoApproveSettingsProps = HTMLAttributes & { allowedCommands?: string[] allowedMaxRequests?: number | undefined allowedMaxCost?: number | undefined - deniedCommands?: string[] + deniedCommands?: DeniedCommand[] setCachedStateField: SetCachedStateField< | "alwaysAllowReadOnly" | "alwaysAllowReadOnlyOutsideWorkspace" @@ -84,6 +85,8 @@ export const AutoApproveSettings = ({ const { t } = useAppTranslation() const [commandInput, setCommandInput] = useState("") const [deniedCommandInput, setDeniedCommandInput] = useState("") + const [deniedCommandMessage, setDeniedCommandMessage] = useState("") + const [showCustomMessage, setShowCustomMessage] = useState(false) const { autoApprovalEnabled, setAutoApprovalEnabled } = useExtensionState() const toggles = useAutoApprovalToggles() @@ -104,11 +107,26 @@ export const AutoApproveSettings = ({ const handleAddDeniedCommand = () => { const currentCommands = deniedCommands ?? [] - if (deniedCommandInput && !currentCommands.includes(deniedCommandInput)) { - const newCommands = [...currentCommands, deniedCommandInput] - setCachedStateField("deniedCommands", newCommands) - setDeniedCommandInput("") - vscode.postMessage({ type: "deniedCommands", commands: newCommands }) + if (deniedCommandInput) { + // Check if this prefix already exists + const existingIndex = currentCommands.findIndex( + (cmd) => (typeof cmd === "string" ? cmd : cmd.prefix) === deniedCommandInput, + ) + + if (existingIndex === -1) { + // Add new denied command + const newCommand: DeniedCommand = + showCustomMessage && deniedCommandMessage.trim() + ? { prefix: deniedCommandInput, message: deniedCommandMessage.trim() } + : deniedCommandInput + + const newCommands = [...currentCommands, newCommand] + setCachedStateField("deniedCommands", newCommands) + setDeniedCommandInput("") + setDeniedCommandMessage("") + setShowCustomMessage(false) + vscode.postMessage({ type: "deniedCommands", commands: newCommands }) + } } } @@ -356,45 +374,94 @@ export const AutoApproveSettings = ({ -
- setDeniedCommandInput(e.target.value)} - onKeyDown={(e: any) => { - if (e.key === "Enter") { - e.preventDefault() - handleAddDeniedCommand() - } - }} - placeholder={t("settings:autoApprove.execute.deniedCommandPlaceholder")} - className="grow" - data-testid="denied-command-input" - /> - +
+
+ setDeniedCommandInput(e.target.value)} + onKeyDown={(e: any) => { + if (e.key === "Enter" && !showCustomMessage) { + e.preventDefault() + handleAddDeniedCommand() + } + }} + placeholder={t("settings:autoApprove.execute.deniedCommandPlaceholder")} + className="grow" + data-testid="denied-command-input" + /> + +
+ +
+ { + setShowCustomMessage(e.target.checked) + if (!e.target.checked) { + setDeniedCommandMessage("") + } + }} + data-testid="custom-message-checkbox"> + + {t("settings:autoApprove.execute.useCustomMessage")} + + +
+ + {showCustomMessage && ( + setDeniedCommandMessage(e.target.value)} + onKeyDown={(e: any) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddDeniedCommand() + } + }} + placeholder={t("settings:autoApprove.execute.customMessagePlaceholder")} + className="w-full" + data-testid="custom-message-input" + /> + )}
- {(deniedCommands ?? []).map((cmd, index) => ( - - ))} + {(deniedCommands ?? []).map((cmd, index) => { + const prefix = typeof cmd === "string" ? cmd : cmd.prefix + const hasCustomMessage = typeof cmd === "object" && cmd.message + + return ( + + + + ) + })}
)} diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index da7ab63358..616303672e 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -7,6 +7,7 @@ import { type ModeConfig, type ExperimentId, type OrganizationAllowList, + type DeniedCommand, ORGANIZATION_ALLOW_ALL, } from "@roo-code/types" @@ -69,7 +70,7 @@ export interface ExtensionStateContextType extends ExtensionState { setShowRooIgnoredFiles: (value: boolean) => void setShowAnnouncement: (value: boolean) => void setAllowedCommands: (value: string[]) => void - setDeniedCommands: (value: string[]) => void + setDeniedCommands: (value: DeniedCommand[]) => void setAllowedMaxRequests: (value: number | undefined) => void setAllowedMaxCost: (value: number | undefined) => void setSoundEnabled: (value: boolean) => void diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index e3f2713ffe..bfdb24f530 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "Introduïu prefix de comanda (ex. 'git ')", "deniedCommandPlaceholder": "Introduïu prefix de comanda a denegar (ex. 'rm -rf')", "addButton": "Afegir", - "autoDenied": "Les comandes amb el prefix `{{prefix}}` han estat prohibides per l'usuari. No eludeixis aquesta restricció executant una altra comanda." + "autoDenied": "Les comandes amb el prefix `{{prefix}}` han estat prohibides per l'usuari. No eludeixis aquesta restricció executant una altra comanda.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index a4a62b8391..4d5a61656f 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "Befehlspräfix eingeben (z.B. 'git ')", "deniedCommandPlaceholder": "Befehlspräfix zum Verweigern eingeben (z.B. 'rm -rf')", "addButton": "Hinzufügen", - "autoDenied": "Befehle mit dem Präfix `{{prefix}}` wurden vom Benutzer verboten. Umgehe diese Beschränkung nicht durch das Ausführen eines anderen Befehls." + "autoDenied": "Befehle mit dem Präfix `{{prefix}}` wurden vom Benutzer verboten. Umgehe diese Beschränkung nicht durch das Ausführen eines anderen Befehls.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 6e0f137504..ffb2cd29f3 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -185,7 +185,11 @@ "commandPlaceholder": "Enter command prefix (e.g., 'git ')", "deniedCommandPlaceholder": "Enter command prefix to deny (e.g., 'rm -rf')", "addButton": "Add", - "autoDenied": "Commands with the prefix `{{prefix}}` have been forbidden by the user. Do not bypass this restriction by running another command." + "autoDenied": "Commands with the prefix `{{prefix}}` have been forbidden by the user. Do not bypass this restriction by running another command.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index e2db1463af..e7cc0f67a5 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "Ingrese prefijo de comando (ej. 'git ')", "deniedCommandPlaceholder": "Ingrese prefijo de comando a denegar (ej. 'rm -rf')", "addButton": "Añadir", - "autoDenied": "Los comandos con el prefijo `{{prefix}}` han sido prohibidos por el usuario. No eludes esta restricción ejecutando otro comando." + "autoDenied": "Los comandos con el prefijo `{{prefix}}` han sido prohibidos por el usuario. No eludes esta restricción ejecutando otro comando.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 26018a344b..7f16885f19 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -188,7 +188,11 @@ "commandPlaceholder": "Entrez le préfixe de commande (ex. 'git ')", "deniedCommandPlaceholder": "Entrez le préfixe de commande à refuser (ex. 'rm -rf')", "addButton": "Ajouter", - "autoDenied": "Les commandes avec le préfixe `{{prefix}}` ont été interdites par l'utilisateur. Ne contourne pas cette restriction en exécutant une autre commande." + "autoDenied": "Les commandes avec le préfixe `{{prefix}}` ont été interdites par l'utilisateur. Ne contourne pas cette restriction en exécutant une autre commande.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 7c8b280427..7a0979e030 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "कमांड प्रीफिक्स दर्ज करें (उदा. 'git ')", "deniedCommandPlaceholder": "अस्वीकार करने के लिए कमांड प्रीफिक्स दर्ज करें (उदा. 'rm -rf')", "addButton": "जोड़ें", - "autoDenied": "प्रीफिक्स `{{prefix}}` वाले कमांड उपयोगकर्ता द्वारा प्रतिबंधित किए गए हैं। दूसरा कमांड चलाकर इस प्रतिबंध को दरकिनार न करें।" + "autoDenied": "प्रीफिक्स `{{prefix}}` वाले कमांड उपयोगकर्ता द्वारा प्रतिबंधित किए गए हैं। दूसरा कमांड चलाकर इस प्रतिबंध को दरकिनार न करें।", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "टूडू", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index b4f9b113b3..da6bfacc77 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "Masukkan prefix perintah (misalnya, 'git ')", "deniedCommandPlaceholder": "Masukkan prefix perintah untuk ditolak (misalnya, 'rm -rf')", "addButton": "Tambah", - "autoDenied": "Perintah dengan awalan `{{prefix}}` telah dilarang oleh pengguna. Jangan menghindari pembatasan ini dengan menjalankan perintah lain." + "autoDenied": "Perintah dengan awalan `{{prefix}}` telah dilarang oleh pengguna. Jangan menghindari pembatasan ini dengan menjalankan perintah lain.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "showMenu": { "label": "Tampilkan menu auto-approve di tampilan chat", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 82d7b2d041..93b2c30e9f 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "Inserisci prefisso comando (es. 'git ')", "deniedCommandPlaceholder": "Inserisci prefisso comando da negare (es. 'rm -rf')", "addButton": "Aggiungi", - "autoDenied": "I comandi con il prefisso `{{prefix}}` sono stati vietati dall'utente. Non aggirare questa restrizione eseguendo un altro comando." + "autoDenied": "I comandi con il prefisso `{{prefix}}` sono stati vietati dall'utente. Non aggirare questa restrizione eseguendo un altro comando.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index dfa62ab32b..cc2005cf2d 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "コマンドプレフィックスを入力(例:'git ')", "deniedCommandPlaceholder": "拒否するコマンドプレフィックスを入力(例:'rm -rf')", "addButton": "追加", - "autoDenied": "プレフィックス `{{prefix}}` を持つコマンドはユーザーによって禁止されています。別のコマンドを実行してこの制限を回避しないでください。" + "autoDenied": "プレフィックス `{{prefix}}` を持つコマンドはユーザーによって禁止されています。別のコマンドを実行してこの制限を回避しないでください。", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 219a5de54a..0e230c41fe 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "명령 접두사 입력(예: 'git ')", "deniedCommandPlaceholder": "거부할 명령 접두사 입력(예: 'rm -rf')", "addButton": "추가", - "autoDenied": "접두사 `{{prefix}}`를 가진 명령어는 사용자에 의해 금지되었습니다. 다른 명령어를 실행하여 이 제한을 우회하지 마세요." + "autoDenied": "접두사 `{{prefix}}`를 가진 명령어는 사용자에 의해 금지되었습니다. 다른 명령어를 실행하여 이 제한을 우회하지 마세요.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 735339bb66..4a8b267b0b 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "Voer commando-prefix in (bijv. 'git ')", "deniedCommandPlaceholder": "Voer te weigeren commando-prefix in (bijv. 'rm -rf')", "addButton": "Toevoegen", - "autoDenied": "Commando's met het prefix `{{prefix}}` zijn verboden door de gebruiker. Omzeil deze beperking niet door een ander commando uit te voeren." + "autoDenied": "Commando's met het prefix `{{prefix}}` zijn verboden door de gebruiker. Omzeil deze beperking niet door een ander commando uit te voeren.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index e25eee34cf..c43200fdca 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "Wprowadź prefiks polecenia (np. 'git ')", "deniedCommandPlaceholder": "Wprowadź prefiks polecenia do odrzucenia (np. 'rm -rf')", "addButton": "Dodaj", - "autoDenied": "Polecenia z prefiksem `{{prefix}}` zostały zabronione przez użytkownika. Nie obchodź tego ograniczenia uruchamiając inne polecenie." + "autoDenied": "Polecenia z prefiksem `{{prefix}}` zostały zabronione przez użytkownika. Nie obchodź tego ograniczenia uruchamiając inne polecenie.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index e7243aa6f6..d1d26dccbb 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "Digite o prefixo do comando (ex. 'git ')", "deniedCommandPlaceholder": "Digite o prefixo do comando para negar (ex. 'rm -rf')", "addButton": "Adicionar", - "autoDenied": "Comandos com o prefixo `{{prefix}}` foram proibidos pelo usuário. Não contorne esta restrição executando outro comando." + "autoDenied": "Comandos com o prefixo `{{prefix}}` foram proibidos pelo usuário. Não contorne esta restrição executando outro comando.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 38e986ab88..4df5092723 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "Введите префикс команды (например, 'git ')", "deniedCommandPlaceholder": "Введите префикс команды для запрета (например, 'rm -rf')", "addButton": "Добавить", - "autoDenied": "Команды с префиксом `{{prefix}}` были запрещены пользователем. Не обходи это ограничение, выполняя другую команду." + "autoDenied": "Команды с префиксом `{{prefix}}` были запрещены пользователем. Не обходи это ограничение, выполняя другую команду.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index b4b9fd13e4..5ac5694454 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "Komut öneki girin (örn. 'git ')", "deniedCommandPlaceholder": "Reddetmek için komut öneki girin (örn. 'rm -rf')", "addButton": "Ekle", - "autoDenied": "`{{prefix}}` önekli komutlar kullanıcı tarafından yasaklandı. Başka bir komut çalıştırarak bu kısıtlamayı aşma." + "autoDenied": "`{{prefix}}` önekli komutlar kullanıcı tarafından yasaklandı. Başka bir komut çalıştırarak bu kısıtlamayı aşma.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index cdae509d5e..d0e17b15ae 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "Nhập tiền tố lệnh (ví dụ: 'git ')", "deniedCommandPlaceholder": "Nhập tiền tố lệnh để từ chối (ví dụ: 'rm -rf')", "addButton": "Thêm", - "autoDenied": "Các lệnh có tiền tố `{{prefix}}` đã bị người dùng cấm. Đừng vượt qua hạn chế này bằng cách chạy lệnh khác." + "autoDenied": "Các lệnh có tiền tố `{{prefix}}` đã bị người dùng cấm. Đừng vượt qua hạn chế này bằng cách chạy lệnh khác.", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "Todo", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index aca901cc3e..333d1130a9 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "输入命令前缀(例如 'git ')", "deniedCommandPlaceholder": "输入要拒绝的命令前缀(例如 'rm -rf')", "addButton": "添加", - "autoDenied": "前缀为 `{{prefix}}` 的命令已被用户禁止。不要通过运行其他命令来绕过此限制。" + "autoDenied": "前缀为 `{{prefix}}` 的命令已被用户禁止。不要通过运行其他命令来绕过此限制。", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "待办", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index db3fc3c2cd..72d233b3da 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -187,7 +187,11 @@ "commandPlaceholder": "輸入命令前綴(例如 'git ')", "deniedCommandPlaceholder": "輸入要拒絕的命令前綴(例如 'rm -rf')", "addButton": "新增", - "autoDenied": "前綴為 `{{prefix}}` 的命令已被使用者禁止。不要透過執行其他命令來繞過此限制。" + "autoDenied": "前綴為 `{{prefix}}` 的命令已被使用者禁止。不要透過執行其他命令來繞過此限制。", + "useCustomMessage": "Use custom message", + "customMessagePlaceholder": "Enter custom message for this command prefix", + "defaultDeniedMessage": "Commands with the prefix '{{prefix}}' have been forbidden by the user. Do not bypass this restriction by running another command.", + "hasCustomMessage": "Has custom message" }, "updateTodoList": { "label": "待辦", diff --git a/webview-ui/src/utils/__tests__/command-validation.spec.ts b/webview-ui/src/utils/__tests__/command-validation.spec.ts index 29370c471f..fbbab96ce2 100644 --- a/webview-ui/src/utils/__tests__/command-validation.spec.ts +++ b/webview-ui/src/utils/__tests__/command-validation.spec.ts @@ -12,6 +12,8 @@ import { CommandValidator, createCommandValidator, containsSubshell, + findLongestDeniedMatch, + deniedCommandsToPrefixes, } from "../command-validation" describe("Command Validation", () => { @@ -1189,3 +1191,142 @@ describe("Unified Command Decision Functions", () => { }) }) }) + +describe("Custom Denied Command Messages", () => { + describe("deniedCommandsToPrefixes", () => { + it("converts string array to prefixes", () => { + const result = deniedCommandsToPrefixes(["rm", "sudo", "chmod"]) + expect(result).toEqual(["rm", "sudo", "chmod"]) + }) + + it("converts DeniedCommand objects to prefixes", () => { + const result = deniedCommandsToPrefixes([ + { prefix: "rm", message: "Dangerous!" }, + { prefix: "sudo" }, + "chmod", + ]) + expect(result).toEqual(["rm", "sudo", "chmod"]) + }) + + it("handles empty array", () => { + const result = deniedCommandsToPrefixes([]) + expect(result).toEqual([]) + }) + + it("handles undefined", () => { + const result = deniedCommandsToPrefixes(undefined as any) + expect(result).toEqual([]) + }) + }) + + describe("findLongestDeniedMatch", () => { + it("returns null when no match found", () => { + const deniedCommands = ["rm", "sudo"] + const result = findLongestDeniedMatch("echo hello", deniedCommands) + expect(result).toEqual({ prefix: null, message: undefined }) + }) + + it("finds match with string array", () => { + const deniedCommands = ["rm", "sudo", "chmod"] + const result = findLongestDeniedMatch("rm -rf /", deniedCommands) + expect(result).toEqual({ prefix: "rm", message: undefined }) + }) + + it("finds match with DeniedCommand objects", () => { + const deniedCommands = [ + { prefix: "rm", message: "File deletion is not allowed" }, + { prefix: "sudo", message: "Elevated privileges are forbidden" }, + "chmod", + ] + const result = findLongestDeniedMatch("rm -rf /", deniedCommands) + expect(result).toEqual({ prefix: "rm", message: "File deletion is not allowed" }) + }) + + it("returns custom message when available", () => { + const deniedCommands = [{ prefix: "sudo", message: "No admin access allowed!" }] + const result = findLongestDeniedMatch("sudo apt-get update", deniedCommands) + expect(result).toEqual({ prefix: "sudo", message: "No admin access allowed!" }) + }) + + it("returns undefined message for string entries", () => { + const deniedCommands = ["sudo"] + const result = findLongestDeniedMatch("sudo apt-get update", deniedCommands) + expect(result).toEqual({ prefix: "sudo", message: undefined }) + }) + + it("finds longest matching prefix", () => { + const deniedCommands = [ + { prefix: "git", message: "Git commands restricted" }, + { prefix: "git push", message: "Pushing to remote is forbidden" }, + "git pull", + ] + const result = findLongestDeniedMatch("git push origin main", deniedCommands) + expect(result).toEqual({ prefix: "git push", message: "Pushing to remote is forbidden" }) + }) + + it("handles case insensitive matching", () => { + const deniedCommands = [{ prefix: "RM", message: "No file deletion" }] + const result = findLongestDeniedMatch("rm -rf /", deniedCommands) + expect(result).toEqual({ prefix: "rm", message: "No file deletion" }) + }) + + it("handles empty command", () => { + const deniedCommands = [{ prefix: "rm" }] + const result = findLongestDeniedMatch("", deniedCommands) + expect(result).toEqual({ prefix: null, message: undefined }) + }) + + it("handles empty denied commands", () => { + const result = findLongestDeniedMatch("rm -rf /", []) + expect(result).toEqual({ prefix: null, message: undefined }) + }) + + it("handles undefined denied commands", () => { + const result = findLongestDeniedMatch("rm -rf /", undefined as any) + expect(result).toEqual({ prefix: null, message: undefined }) + }) + }) + + describe("Integration with existing functions", () => { + it("getCommandDecision works with DeniedCommand objects", () => { + const allowedCommands = ["echo", "ls"] + const deniedCommands = [{ prefix: "rm", message: "File deletion is dangerous" }, "sudo"] + + // Should auto-deny with custom message available + expect(getCommandDecision("rm -rf /", allowedCommands, deniedCommands)).toBe("auto_deny") + + // Should auto-deny without custom message + expect(getCommandDecision("sudo apt-get", allowedCommands, deniedCommands)).toBe("auto_deny") + + // Should auto-approve allowed commands + expect(getCommandDecision("echo hello", allowedCommands, deniedCommands)).toBe("auto_approve") + }) + + it("CommandValidator works with DeniedCommand objects", () => { + const validator = new CommandValidator( + ["npm", "echo"], + [ + { prefix: "rm", message: "Deletion not allowed" }, + { prefix: "sudo", message: "Admin access forbidden" }, + "chmod", + ], + ) + + expect(validator.validateCommand("rm file.txt")).toBe("auto_deny") + expect(validator.validateCommand("sudo command")).toBe("auto_deny") + expect(validator.validateCommand("chmod +x file")).toBe("auto_deny") + expect(validator.validateCommand("npm install")).toBe("auto_approve") + }) + + it("CommandValidator getValidationDetails includes denied command info", () => { + const validator = new CommandValidator(["npm"], [{ prefix: "rm", message: "Custom denial message" }]) + + const details = validator.getValidationDetails("rm -rf /") + expect(details.decision).toBe("auto_deny") + expect(details.deniedMatches[0]).toEqual({ + command: "rm -rf /", + match: "rm", + }) + }) + }) +}) diff --git a/webview-ui/src/utils/command-validation.ts b/webview-ui/src/utils/command-validation.ts index 700aed554b..aa0c09d700 100644 --- a/webview-ui/src/utils/command-validation.ts +++ b/webview-ui/src/utils/command-validation.ts @@ -1,4 +1,5 @@ import { parse } from "shell-quote" +import type { DeniedCommand } from "@roo-code/types" type ShellToken = string | { op: string } | { command: string } @@ -380,6 +381,52 @@ export function findLongestPrefixMatch(command: string, prefixes: string[]): str return longestMatch } +/** + * Find the longest matching denied command (with optional custom message) from a list of denied commands. + * + * @param command - The command to match against + * @param deniedCommands - List of denied commands (string or object format) + * @returns The matching denied command object with prefix and optional message, or null if no match found + */ +export function findLongestDeniedMatch( + command: string, + deniedCommands: DeniedCommand[], +): { prefix: string | null; message?: string } { + if (!command || !deniedCommands?.length) return { prefix: null, message: undefined } + + const trimmedCommand = command.trim().toLowerCase() + let longestMatch: { prefix: string; message?: string } | null = null + + for (const denied of deniedCommands) { + const prefix = typeof denied === "string" ? denied : denied.prefix + const lowerPrefix = prefix.toLowerCase() + + // Handle wildcard "*" - it matches any command + if (lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)) { + if (!longestMatch || lowerPrefix.length > longestMatch.prefix.length) { + longestMatch = { + prefix: lowerPrefix, + message: typeof denied === "object" ? denied.message : undefined, + } + } + } + } + + return longestMatch || { prefix: null, message: undefined } +} + +/** + * Convert a list of DeniedCommand objects to a list of prefix strings. + * This is used for backward compatibility with existing code. + * + * @param deniedCommands - List of denied commands (string or object format) + * @returns List of prefix strings + */ +export function deniedCommandsToPrefixes(deniedCommands: DeniedCommand[]): string[] { + if (!deniedCommands) return [] + return deniedCommands.map((cmd) => (typeof cmd === "string" ? cmd : cmd.prefix)) +} + /** * Check if a single command should be auto-approved. * Returns true only for commands that explicitly match the allowlist @@ -508,10 +555,13 @@ export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user" export function getCommandDecision( command: string, allowedCommands: string[], - deniedCommands?: string[], + deniedCommands?: DeniedCommand[], ): CommandDecision { if (!command?.trim()) return "auto_approve" + // Convert DeniedCommand[] to string[] for backward compatibility + const deniedPrefixes = deniedCommands ? deniedCommandsToPrefixes(deniedCommands) : undefined + // Parse into sub-commands (split by &&, ||, ;, |) const subCommands = parseCommand(command) @@ -520,7 +570,7 @@ export function getCommandDecision( // Remove simple PowerShell-like redirections (e.g. 2>&1) before checking const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim() - return getSingleCommandDecision(cmdWithoutRedirection, allowedCommands, deniedCommands) + return getSingleCommandDecision(cmdWithoutRedirection, allowedCommands, deniedPrefixes) }) // If any sub-command is denied, deny the whole command @@ -622,13 +672,13 @@ export function getSingleCommandDecision( export class CommandValidator { constructor( private allowedCommands: string[], - private deniedCommands?: string[], + private deniedCommands?: DeniedCommand[], ) {} /** * Update the command lists used for validation */ - updateCommandLists(allowedCommands: string[], deniedCommands?: string[]) { + updateCommandLists(allowedCommands: string[], deniedCommands?: DeniedCommand[]) { this.allowedCommands = allowedCommands this.deniedCommands = deniedCommands } @@ -693,7 +743,10 @@ export class CommandValidator { const deniedMatches = subCommands.map((cmd) => ({ command: cmd, - match: findLongestPrefixMatch(cmd.replace(/\d*>&\d*/, "").trim(), this.deniedCommands || []), + match: findLongestPrefixMatch( + cmd.replace(/\d*>&\d*/, "").trim(), + deniedCommandsToPrefixes(this.deniedCommands || []), + ), })) return { @@ -741,6 +794,6 @@ export class CommandValidator { * Factory function to create a CommandValidator instance * This is the recommended way to create validators in the application */ -export function createCommandValidator(allowedCommands: string[], deniedCommands?: string[]): CommandValidator { +export function createCommandValidator(allowedCommands: string[], deniedCommands?: DeniedCommand[]): CommandValidator { return new CommandValidator(allowedCommands, deniedCommands) }