Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
17 changes: 15 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[]
Comment on lines +1687 to +1689
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type checking logic assumes that if the first element is an object, all elements are objects (line 1688). This is unsafe for arrays with mixed types. If globalStateCommands contains a mix like ["cmd1", {command: "cmd2"}], this code would incorrectly cast the entire array as objects, causing runtime errors when accessing .command on string elements. While the zod schema should prevent mixed arrays, defensive coding would handle this gracefully. Consider checking each element individually or using Array.every() to verify all elements match the expected type before casting.

}

// If it's in the old string format, convert to new format for compatibility
return (globalStateCommands as string[]).map((cmd) => ({ command: cmd }))
}
Comment on lines +1679 to 1694
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new mergeDeniedCommands implementation no longer merges workspace-level denied commands with global-level ones. The original implementation delegated to mergeCommandLists which retrieved workspace configuration via vscode.workspace.getConfiguration(Package.name).get<string[]>(configKey) and merged it with global state. Now it only returns the converted global state commands without fetching workspace config. This breaks the documented merging behavior where workspace and global configurations are combined. The mergeAllowedCommands method on line 1671 still uses mergeCommandLists and correctly merges both sources. The fix should either call mergeCommandLists first or replicate its logic to fetch and merge workspace config.


/**
Expand Down
28 changes: 22 additions & 6 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 34 additions & 19 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1028,7 +1023,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const getCommandDecisionForMessage = useCallback(
(message: ClineMessage | undefined): CommandDecision => {
if (message?.type !== "ask") return "ask_user"
return getCommandDecision(message.text || "", allowedCommands || [], deniedCommands || [])
// Convert deniedCommands to string array for getCommandDecision
const deniedCommandsArray =
deniedCommands?.map((cmd) => (typeof cmd === "string" ? cmd : cmd.command)) || []
return getCommandDecision(message.text || "", allowedCommands || [], deniedCommandsArray)
},
[allowedCommands, deniedCommands],
)
Expand All @@ -1049,17 +1047,32 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
[getCommandDecisionForMessage],
)

// Helper function to get the denied prefix for a command
const getDeniedPrefix = useCallback(
(command: string): string | null => {
// Helper function to get the denied command and its custom message
const getDeniedCommand = useCallback(
(command: string): { prefix: string; message?: string } | null => {
if (!command || !deniedCommands?.length) return null

// Normalize deniedCommands to objects
const normalizedDenied = deniedCommands.map((cmd) => (typeof cmd === "string" ? { command: cmd } : cmd))

// Parse the command into sub-commands and check each one
const subCommands = parseCommand(command)
for (const cmd of subCommands) {
const deniedMatch = findLongestPrefixMatch(cmd, deniedCommands)
if (deniedMatch) {
return deniedMatch
// Find longest matching denied command
let longestMatch: { command: string; message?: string } | null = null
let longestLength = 0

for (const denied of normalizedDenied) {
if (cmd.toLowerCase().startsWith(denied.command.toLowerCase())) {
if (denied.command.length > longestLength) {
longestMatch = denied
longestLength = denied.command.length
}
Comment on lines +1064 to +1070
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getDeniedCommand function reimplements the longest prefix matching logic that already exists in findLongestPrefixMatch from the command-validation utility (which was removed from imports on line 29). This creates code duplication and maintenance burden. The manual loop on lines 1064-1070 duplicates the exact logic of findLongestPrefixMatch. Instead, this function should re-add findLongestPrefixMatch to imports and use it to find the matching command prefix, then look up the corresponding object from deniedCommands to get the custom message. This would reduce duplication and ensure consistent matching behavior.

}
}

if (longestMatch) {
return { prefix: longestMatch.command, message: longestMatch.message }
}
}
return null
Expand Down Expand Up @@ -1582,11 +1595,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const autoApproveOrReject = async () => {
// Check for auto-reject first (commands that should be denied)
if (lastMessage?.ask === "command" && isDeniedCommand(lastMessage)) {
// Get the denied prefix for the localized message
const deniedPrefix = getDeniedPrefix(lastMessage.text || "")
if (deniedPrefix) {
// Create the localized auto-deny message and send it with the rejection
const autoDenyMessage = tSettings("autoApprove.execute.autoDenied", { prefix: deniedPrefix })
// Get the denied command and its custom message
const deniedCommand = getDeniedCommand(lastMessage.text || "")
if (deniedCommand) {
// Use custom message if provided, otherwise use default localized message
const autoDenyMessage =
deniedCommand.message ||
tSettings("autoApprove.execute.autoDenied", { prefix: deniedCommand.prefix })

vscode.postMessage({
type: "askResponse",
Expand Down Expand Up @@ -1686,7 +1701,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleSuggestionClickInRow,
isAllowedCommand,
isDeniedCommand,
getDeniedPrefix,
getDeniedCommand,
tSettings,
])

Expand Down
114 changes: 82 additions & 32 deletions webview-ui/src/components/settings/AutoApproveSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
allowedCommands?: string[]
allowedMaxRequests?: number | undefined
allowedMaxCost?: number | undefined
deniedCommands?: string[]
deniedCommands?: string[] | { command: string; message?: string }[]
setCachedStateField: SetCachedStateField<
| "alwaysAllowReadOnly"
| "alwaysAllowReadOnlyOutsideWorkspace"
Expand Down Expand Up @@ -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()
Expand All @@ -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 })
}
}
Expand Down Expand Up @@ -361,45 +372,84 @@ export const AutoApproveSettings = ({
</div>
</div>

<div className="flex gap-2">
<div className="space-y-2">
<div className="flex gap-2">
<Input
value={deniedCommandInput}
onChange={(e: any) => setDeniedCommandInput(e.target.value)}
onKeyDown={(e: any) => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using proper event types instead of any in onKeyDown handlers for better type safety.

Suggested change
onKeyDown={(e: any) => {
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {

if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleAddDeniedCommand()
}
}}
placeholder={t("settings:autoApprove.execute.deniedCommandPlaceholder")}
className="grow"
data-testid="denied-command-input"
/>
<Button
className="h-8"
onClick={handleAddDeniedCommand}
data-testid="add-denied-command-button">
{t("settings:autoApprove.execute.addButton")}
</Button>
</div>
<Input
value={deniedCommandInput}
onChange={(e: any) => setDeniedCommandInput(e.target.value)}
value={deniedMessageInput}
onChange={(e: any) => setDeniedMessageInput(e.target.value)}
onKeyDown={(e: any) => {
if (e.key === "Enter") {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleAddDeniedCommand()
}
}}
placeholder={t("settings:autoApprove.execute.deniedCommandPlaceholder")}
className="grow"
data-testid="denied-command-input"
placeholder={t("settings:autoApprove.execute.customMessagePlaceholder", {
defaultValue: "Custom message (optional, e.g., 'Use uv run python instead')",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove the inline fallback English string (defaultValue) from the translation call for customMessagePlaceholder. The system automatically falls back to the English JSON if a translation is missing.

Suggested change
defaultValue: "Custom message (optional, e.g., 'Use uv run python instead')",

This comment was generated because it violated a code review rule: irule_C0ez7Rji6ANcGkkX.

})}
className="w-full text-sm"
data-testid="denied-message-input"
/>
<Button
className="h-8"
onClick={handleAddDeniedCommand}
data-testid="add-denied-command-button">
{t("settings:autoApprove.execute.addButton")}
</Button>
</div>

<div className="flex flex-wrap gap-2">
{(deniedCommands ?? []).map((cmd, index) => (
<Button
key={index}
variant="secondary"
data-testid={`remove-denied-command-${index}`}
onClick={() => {
const newCommands = (deniedCommands ?? []).filter((_, i) => i !== index)
setCachedStateField("deniedCommands", newCommands)
vscode.postMessage({ type: "deniedCommands", commands: newCommands })
}}>
<div className="flex flex-row items-center gap-1">
<div>{cmd}</div>
<X className="text-foreground scale-75" />
<div className="flex flex-col gap-2">
{(deniedCommands ?? []).map((cmd, index) => {
const commandStr = typeof cmd === "string" ? cmd : cmd.command
const messageStr = typeof cmd === "string" ? null : cmd.message

return (
<div
key={index}
className="flex flex-col gap-1 p-2 rounded border border-vscode-panel-border bg-vscode-editor-background">
<div className="flex items-center justify-between">
<code className="text-sm">{commandStr}</code>
<Button
variant="ghost"
size="sm"
data-testid={`remove-denied-command-${index}`}
onClick={() => {
const newCommands = (deniedCommands ?? []).filter(
(_, i) => i !== index,
)
setCachedStateField("deniedCommands", newCommands)
vscode.postMessage({
type: "deniedCommands",
commands: newCommands,
})
}}>
<X className="h-4 w-4" />
</Button>
</div>
{messageStr && (
<div className="text-xs text-vscode-descriptionForeground mt-1">
{t("settings:autoApprove.execute.customMessage", {
defaultValue: "Custom message:",
})}{" "}
{messageStr}
</div>
)}
</div>
</Button>
))}
)
})}
</div>
</div>
)}
Expand Down
2 changes: 1 addition & 1 deletion webview-ui/src/context/ExtensionStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions webview-ui/src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
Loading