-
Notifications
You must be signed in to change notification settings - Fork 2.3k
feat: add custom denial messages for auto-denied commands #8704
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 }[] | ||
} | ||
|
||
// 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new |
||
|
||
/** | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
|
@@ -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], | ||
) | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
} | ||
} | ||
|
||
if (longestMatch) { | ||
return { prefix: longestMatch.command, message: longestMatch.message } | ||
} | ||
} | ||
return null | ||
|
@@ -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", | ||
|
@@ -1686,7 +1701,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro | |
handleSuggestionClickInRow, | ||
isAllowedCommand, | ||
isDeniedCommand, | ||
getDeniedPrefix, | ||
getDeniedCommand, | ||
tSettings, | ||
]) | ||
|
||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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" | ||||||
|
@@ -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() | ||||||
|
@@ -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 }) | ||||||
} | ||||||
} | ||||||
|
@@ -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) => { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider using proper event types instead of
Suggested change
|
||||||
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')", | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove the inline fallback English string (
Suggested change
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> | ||||||
)} | ||||||
|
There was a problem hiding this comment.
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 usingArray.every()
to verify all elements match the expected type before casting.