feat: implement customizable forbidden command messages

- Updated configuration schema to support both string and object formats for denied commands
- Added DeniedCommand type to support custom messages per command prefix
- Modified frontend components to handle and display custom messages
- Updated localization files with new translation keys
- Added comprehensive tests for the new functionality
- Maintained backward compatibility with existing string-based configuration

Fixes #6754
This commit is contained in:
Roo Code 2025-08-06 17:10:33 +00:00
parent 2b647ed9a1
commit 7b3c087231
29 changed files with 537 additions and 94 deletions

View file

@ -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<typeof deniedCommandSchema>
/**
* 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(),

View file

@ -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<string, string | { prefix: string; message?: string }>()
// 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 []
}
}
/**

View file

@ -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
}

View file

@ -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%"

View file

@ -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

View file

@ -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<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 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<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 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<ChatViewRef, ChatViewPro
handleSuggestionClickInRow,
isAllowedCommand,
isDeniedCommand,
getDeniedPrefix,
getDeniedCommandInfo,
tSettings,
])

View file

@ -15,7 +15,7 @@ import { cn } from "@src/lib/utils"
import { Button } from "@src/components/ui"
import CodeBlock from "../common/CodeBlock"
import { CommandPatternSelector } from "./CommandPatternSelector"
import { parseCommand } from "../../utils/command-validation"
import { parseCommand, deniedCommandsToPrefixes } from "../../utils/command-validation"
import { extractPatternsFromCommand } from "../../utils/command-parser"
interface CommandPattern {
@ -82,7 +82,10 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
const handleAllowPatternChange = (pattern: string) => {
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
<CommandPatternSelector
patterns={commandPatterns}
allowedCommands={allowedCommands}
deniedCommands={deniedCommands}
deniedCommands={deniedCommandsToPrefixes(deniedCommands)}
onAllowPatternChange={handleAllowPatternChange}
onDenyPatternChange={handleDenyPatternChange}
/>

View file

@ -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<HTMLDivElement> & {
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 = ({
</div>
</div>
<div className="flex gap-2">
<Input
value={deniedCommandInput}
onChange={(e: any) => 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"
/>
<Button
className="h-8"
onClick={handleAddDeniedCommand}
data-testid="add-denied-command-button">
{t("settings:autoApprove.execute.addButton")}
</Button>
<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" && !showCustomMessage) {
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>
<div className="flex items-center gap-2">
<VSCodeCheckbox
checked={showCustomMessage}
onChange={(e: any) => {
setShowCustomMessage(e.target.checked)
if (!e.target.checked) {
setDeniedCommandMessage("")
}
}}
data-testid="custom-message-checkbox">
<span className="text-sm">
{t("settings:autoApprove.execute.useCustomMessage")}
</span>
</VSCodeCheckbox>
</div>
{showCustomMessage && (
<Input
value={deniedCommandMessage}
onChange={(e: any) => 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"
/>
)}
</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>
</Button>
))}
{(deniedCommands ?? []).map((cmd, index) => {
const prefix = typeof cmd === "string" ? cmd : cmd.prefix
const hasCustomMessage = typeof cmd === "object" && cmd.message
return (
<StandardTooltip
key={index}
content={
hasCustomMessage
? cmd.message
: t("settings:autoApprove.execute.defaultDeniedMessage", { prefix })
}>
<Button
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>{prefix}</div>
{hasCustomMessage && (
<span className="text-vscode-descriptionForeground">*</span>
)}
<X className="text-foreground scale-75" />
</div>
</Button>
</StandardTooltip>
)
})}
</div>
</div>
)}

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "टूडू",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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": "待办",

View file

@ -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": "待辦",

View file

@ -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",
})
})
})
})

View file

@ -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)
}