Skip to content

Commit 7b3c087

Browse files
committed
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
1 parent 2b647ed commit 7b3c087

29 files changed

+537
-94
lines changed

packages/types/src/global-settings.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,20 @@ export const DEFAULT_WRITE_DELAY_MS = 1000
2929
*/
3030
export const DEFAULT_TERMINAL_OUTPUT_CHARACTER_LIMIT = 50_000
3131

32+
/**
33+
* DeniedCommand type - can be either a string (for backward compatibility)
34+
* or an object with prefix and optional custom message
35+
*/
36+
export const deniedCommandSchema = z.union([
37+
z.string(),
38+
z.object({
39+
prefix: z.string(),
40+
message: z.string().optional(),
41+
}),
42+
])
43+
44+
export type DeniedCommand = z.infer<typeof deniedCommandSchema>
45+
3246
/**
3347
* GlobalSettings
3448
*/
@@ -63,7 +77,7 @@ export const globalSettingsSchema = z.object({
6377
followupAutoApproveTimeoutMs: z.number().optional(),
6478
alwaysAllowUpdateTodoList: z.boolean().optional(),
6579
allowedCommands: z.array(z.string()).optional(),
66-
deniedCommands: z.array(z.string()).optional(),
80+
deniedCommands: z.array(deniedCommandSchema).optional(),
6781
commandExecutionTimeout: z.number().optional(),
6882
commandTimeoutAllowlist: z.array(z.string()).optional(),
6983
preventCompletionWithOpenTodos: z.boolean().optional(),

src/core/webview/ClineProvider.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,8 +1498,62 @@ export class ClineProvider
14981498
* Merges denied commands from global state and workspace configuration
14991499
* with proper validation and deduplication
15001500
*/
1501-
private mergeDeniedCommands(globalStateCommands?: string[]): string[] {
1502-
return this.mergeCommandLists("deniedCommands", "denied", globalStateCommands)
1501+
private mergeDeniedCommands(
1502+
globalStateCommands?: (string | { prefix: string; message?: string })[],
1503+
): (string | { prefix: string; message?: string })[] {
1504+
try {
1505+
// Validate and sanitize global state commands
1506+
const validGlobalCommands = Array.isArray(globalStateCommands)
1507+
? globalStateCommands.filter((cmd) => {
1508+
if (typeof cmd === "string") {
1509+
return cmd.trim().length > 0
1510+
} else if (typeof cmd === "object" && cmd !== null && "prefix" in cmd) {
1511+
return typeof cmd.prefix === "string" && cmd.prefix.trim().length > 0
1512+
}
1513+
return false
1514+
})
1515+
: []
1516+
1517+
// Get workspace configuration commands
1518+
const workspaceCommands =
1519+
vscode.workspace
1520+
.getConfiguration(Package.name)
1521+
.get<(string | { prefix: string; message?: string })[]>("deniedCommands") || []
1522+
1523+
// Validate and sanitize workspace commands
1524+
const validWorkspaceCommands = Array.isArray(workspaceCommands)
1525+
? workspaceCommands.filter((cmd) => {
1526+
if (typeof cmd === "string") {
1527+
return cmd.trim().length > 0
1528+
} else if (typeof cmd === "object" && cmd !== null && "prefix" in cmd) {
1529+
return typeof cmd.prefix === "string" && cmd.prefix.trim().length > 0
1530+
}
1531+
return false
1532+
})
1533+
: []
1534+
1535+
// Combine and deduplicate commands
1536+
// Global state takes precedence over workspace configuration
1537+
const prefixMap = new Map<string, string | { prefix: string; message?: string }>()
1538+
1539+
// Add workspace commands first
1540+
validWorkspaceCommands.forEach((cmd) => {
1541+
const prefix = typeof cmd === "string" ? cmd : cmd.prefix
1542+
prefixMap.set(prefix, cmd)
1543+
})
1544+
1545+
// Add global commands (overwriting workspace if same prefix)
1546+
validGlobalCommands.forEach((cmd) => {
1547+
const prefix = typeof cmd === "string" ? cmd : cmd.prefix
1548+
prefixMap.set(prefix, cmd)
1549+
})
1550+
1551+
return Array.from(prefixMap.values())
1552+
} catch (error) {
1553+
console.error(`Error merging denied commands:`, error)
1554+
// Return empty array as fallback to prevent crashes
1555+
return []
1556+
}
15031557
}
15041558

15051559
/**

src/core/webview/webviewMessageHandler.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
type GlobalState,
1313
type ClineMessage,
1414
TelemetryEventName,
15+
type DeniedCommand,
1516
} from "@roo-code/types"
1617
import { CloudService } from "@roo-code/cloud"
1718
import { TelemetryService } from "@roo-code/telemetry"
@@ -751,7 +752,7 @@ export const webviewMessageHandler = async (
751752
? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0)
752753
: []
753754

754-
await updateGlobalState("allowedCommands", validCommands)
755+
await updateGlobalState("allowedCommands", validCommands as string[])
755756

756757
// Also update workspace settings.
757758
await vscode.workspace
@@ -761,18 +762,26 @@ export const webviewMessageHandler = async (
761762
break
762763
}
763764
case "deniedCommands": {
764-
// Validate and sanitize the commands array
765+
// Validate and sanitize the commands array - now supports both string and object format
765766
const commands = message.commands ?? []
766767
const validCommands = Array.isArray(commands)
767-
? commands.filter((cmd) => typeof cmd === "string" && cmd.trim().length > 0)
768+
? commands.filter((cmd): cmd is string | DeniedCommand => {
769+
if (typeof cmd === "string") {
770+
return cmd.trim().length > 0
771+
} else if (typeof cmd === "object" && cmd !== null && "prefix" in cmd) {
772+
// Validate object format
773+
return typeof cmd.prefix === "string" && cmd.prefix.trim().length > 0
774+
}
775+
return false
776+
})
768777
: []
769778

770779
await updateGlobalState("deniedCommands", validCommands)
771780

772781
// Also update workspace settings.
773782
await vscode.workspace
774783
.getConfiguration(Package.name)
775-
.update("deniedCommands", validCommands, vscode.ConfigurationTarget.Global)
784+
.update("deniedCommands", validCommands as any, vscode.ConfigurationTarget.Global)
776785

777786
break
778787
}

src/package.json

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,28 @@
333333
"roo-cline.deniedCommands": {
334334
"type": "array",
335335
"items": {
336-
"type": "string"
336+
"oneOf": [
337+
{
338+
"type": "string"
339+
},
340+
{
341+
"type": "object",
342+
"properties": {
343+
"prefix": {
344+
"type": "string",
345+
"description": "The command prefix to deny"
346+
},
347+
"message": {
348+
"type": "string",
349+
"description": "Custom message to show when this command is denied"
350+
}
351+
},
352+
"required": [
353+
"prefix"
354+
],
355+
"additionalProperties": false
356+
}
357+
]
337358
},
338359
"default": [],
339360
"description": "%commands.deniedCommands.description%"

src/shared/WebviewMessage.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
InstallMarketplaceItemOptions,
88
MarketplaceItem,
99
ShareVisibility,
10+
DeniedCommand,
1011
} from "@roo-code/types"
1112
import { marketplaceItemSchema } from "@roo-code/types"
1213

@@ -221,7 +222,7 @@ export interface WebviewMessage {
221222
images?: string[]
222223
bool?: boolean
223224
value?: number
224-
commands?: string[]
225+
commands?: string[] | DeniedCommand[]
225226
audioType?: AudioType
226227
serverName?: string
227228
toolName?: string

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { vscode } from "@src/utils/vscode"
2727
import {
2828
getCommandDecision,
2929
CommandDecision,
30-
findLongestPrefixMatch,
30+
findLongestDeniedMatch,
3131
parseCommand,
3232
} from "@src/utils/command-validation"
3333
import { useTranslation } from "react-i18next"
@@ -1077,17 +1077,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
10771077
[getCommandDecisionForMessage],
10781078
)
10791079

1080-
// Helper function to get the denied prefix for a command
1081-
const getDeniedPrefix = useCallback(
1082-
(command: string): string | null => {
1080+
// Helper function to get the denied command info (prefix and optional message) for a command
1081+
const getDeniedCommandInfo = useCallback(
1082+
(command: string): { prefix: string; message?: string } | null => {
10831083
if (!command || !deniedCommands?.length) return null
10841084

10851085
// Parse the command into sub-commands and check each one
10861086
const subCommands = parseCommand(command)
10871087
for (const cmd of subCommands) {
1088-
const deniedMatch = findLongestPrefixMatch(cmd, deniedCommands)
1089-
if (deniedMatch) {
1090-
return deniedMatch
1088+
const deniedMatch = findLongestDeniedMatch(cmd, deniedCommands)
1089+
if (deniedMatch.prefix) {
1090+
return deniedMatch as { prefix: string; message?: string }
10911091
}
10921092
}
10931093
return null
@@ -1606,11 +1606,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
16061606
const autoApproveOrReject = async () => {
16071607
// Check for auto-reject first (commands that should be denied)
16081608
if (lastMessage?.ask === "command" && isDeniedCommand(lastMessage)) {
1609-
// Get the denied prefix for the localized message
1610-
const deniedPrefix = getDeniedPrefix(lastMessage.text || "")
1611-
if (deniedPrefix) {
1612-
// Create the localized auto-deny message and send it with the rejection
1613-
const autoDenyMessage = tSettings("autoApprove.execute.autoDenied", { prefix: deniedPrefix })
1609+
// Get the denied command info (prefix and optional custom message)
1610+
const deniedInfo = getDeniedCommandInfo(lastMessage.text || "")
1611+
if (deniedInfo) {
1612+
// Use custom message if provided, otherwise use the default localized message
1613+
const autoDenyMessage =
1614+
deniedInfo.message || tSettings("autoApprove.execute.autoDenied", { prefix: deniedInfo.prefix })
16141615

16151616
vscode.postMessage({
16161617
type: "askResponse",
@@ -1710,7 +1711,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
17101711
handleSuggestionClickInRow,
17111712
isAllowedCommand,
17121713
isDeniedCommand,
1713-
getDeniedPrefix,
1714+
getDeniedCommandInfo,
17141715
tSettings,
17151716
])
17161717

webview-ui/src/components/chat/CommandExecution.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { cn } from "@src/lib/utils"
1515
import { Button } from "@src/components/ui"
1616
import CodeBlock from "../common/CodeBlock"
1717
import { CommandPatternSelector } from "./CommandPatternSelector"
18-
import { parseCommand } from "../../utils/command-validation"
18+
import { parseCommand, deniedCommandsToPrefixes } from "../../utils/command-validation"
1919
import { extractPatternsFromCommand } from "../../utils/command-parser"
2020

2121
interface CommandPattern {
@@ -82,7 +82,10 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
8282
const handleAllowPatternChange = (pattern: string) => {
8383
const isAllowed = allowedCommands.includes(pattern)
8484
const newAllowed = isAllowed ? allowedCommands.filter((p) => p !== pattern) : [...allowedCommands, pattern]
85-
const newDenied = deniedCommands.filter((p) => p !== pattern)
85+
const newDenied = deniedCommands.filter((cmd) => {
86+
const prefix = typeof cmd === "string" ? cmd : cmd.prefix
87+
return prefix !== pattern
88+
})
8689

8790
setAllowedCommands(newAllowed)
8891
setDeniedCommands(newDenied)
@@ -91,8 +94,14 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
9194
}
9295

9396
const handleDenyPatternChange = (pattern: string) => {
94-
const isDenied = deniedCommands.includes(pattern)
95-
const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern]
97+
const deniedPrefixes = deniedCommandsToPrefixes(deniedCommands)
98+
const isDenied = deniedPrefixes.includes(pattern)
99+
const newDenied = isDenied
100+
? deniedCommands.filter((cmd) => {
101+
const prefix = typeof cmd === "string" ? cmd : cmd.prefix
102+
return prefix !== pattern
103+
})
104+
: [...deniedCommands, pattern]
96105
const newAllowed = allowedCommands.filter((p) => p !== pattern)
97106

98107
setAllowedCommands(newAllowed)
@@ -194,7 +203,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
194203
<CommandPatternSelector
195204
patterns={commandPatterns}
196205
allowedCommands={allowedCommands}
197-
deniedCommands={deniedCommands}
206+
deniedCommands={deniedCommandsToPrefixes(deniedCommands)}
198207
onAllowPatternChange={handleAllowPatternChange}
199208
onDenyPatternChange={handleDenyPatternChange}
200209
/>

0 commit comments

Comments
 (0)