Skip to content
Closed
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
16 changes: 15 additions & 1 deletion packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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(),
Expand Down
58 changes: 56 additions & 2 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
}
}

/**
Expand Down
17 changes: 13 additions & 4 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -761,18 +762,26 @@ 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)

// 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
}
Expand Down
23 changes: 22 additions & 1 deletion src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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%"
Expand Down
3 changes: 2 additions & 1 deletion src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
InstallMarketplaceItemOptions,
MarketplaceItem,
ShareVisibility,
DeniedCommand,
} from "@roo-code/types"
import { marketplaceItemSchema } from "@roo-code/types"

Expand Down Expand Up @@ -221,7 +222,7 @@ export interface WebviewMessage {
images?: string[]
bool?: boolean
value?: number
commands?: string[]
commands?: string[] | DeniedCommand[]
audioType?: AudioType
serverName?: string
toolName?: string
Expand Down
27 changes: 14 additions & 13 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1710,7 +1711,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleSuggestionClickInRow,
isAllowedCommand,
isDeniedCommand,
getDeniedPrefix,
getDeniedCommandInfo,
tSettings,
])

Expand Down
19 changes: 14 additions & 5 deletions webview-ui/src/components/chat/CommandExecution.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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}
/>
Expand Down
Loading
Loading