From 1d9d40085bbfae03fb3f6f4db5b2d9f6d5d9c40f Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Fri, 27 Jun 2025 23:53:42 -0400 Subject: [PATCH] Checkpoint --- packages/types/src/message.ts | 1 + .../presentAssistantMessage.ts | 2 + src/core/prompts/tools/execute-command.ts | 151 +++++++++++++++- src/core/task/Task.ts | 24 ++- src/core/tools/executeCommandTool.ts | 3 +- src/core/webview/webviewMessageHandler.ts | 24 +++ src/shared/WebviewMessage.ts | 1 + src/shared/tools.ts | 4 +- webview-ui/src/components/chat/ChatRow.tsx | 50 ++++- webview-ui/src/components/chat/ChatView.tsx | 171 +++++++++++------- .../src/components/chat/CommandExecution.tsx | 74 +++++++- 11 files changed, 428 insertions(+), 77 deletions(-) diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 914f02ecd6..93ecbab5fe 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -154,6 +154,7 @@ export const clineMessageSchema = z.object({ progressStatus: toolProgressStatusSchema.optional(), contextCondense: contextCondenseSchema.optional(), isProtected: z.boolean().optional(), + commandPrefix: z.string().optional(), }) export type ClineMessage = z.infer diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 21c973ab50..a46c6d4236 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -262,6 +262,7 @@ export async function presentAssistantMessage(cline: Task) { partialMessage?: string, progressStatus?: ToolProgressStatus, isProtected?: boolean, + options?: { prefix?: string }, ) => { const { response, text, images } = await cline.ask( type, @@ -269,6 +270,7 @@ export async function presentAssistantMessage(cline: Task) { false, progressStatus, isProtected || false, + options?.prefix, ) if (response !== "yesButtonClicked") { diff --git a/src/core/prompts/tools/execute-command.ts b/src/core/prompts/tools/execute-command.ts index c1fc1ea3f1..442e62835b 100644 --- a/src/core/prompts/tools/execute-command.ts +++ b/src/core/prompts/tools/execute-command.ts @@ -6,20 +6,167 @@ Description: Request to execute a CLI command on the system. Use this when you n Parameters: - command: (required) The CLI command to execute. This should be valid for the current operating system. Ensure the command is properly formatted and does not contain any harmful instructions. - cwd: (optional) The working directory to execute the command in (default: ${args.cwd}) +- prefix: (optional) The command prefix extracted from the command that represents a logical, safe action. This should capture the specific intent of the command rather than just the executable name. For example, use "npm install" for package installation, "git status" for git operations, "docker build" for Docker builds. Avoid generic prefixes like "python" or "node" that could be used for various purposes including malicious ones. For chained commands (using &&, ||, ;, | etc.), do not provide a prefix as these are too complex for safe auto-approval. The prefix should represent a category of single commands that users would feel safe auto-approving. Usage: Your command here +Command prefix here Working directory path (optional) -Example: Requesting to execute npm run dev +Example: Requesting to execute npm test -npm run dev +npm test +npm test + + +Example: Requesting to execute git status + +git status +git status Example: Requesting to execute ls in a specific directory if directed ls -la +ls /home/user/projects + + +Example: NPM package installation + +npm install express +npm install + + +Example: NPM script execution + +npm run build +npm run + + +Example: Yarn package installation + +yarn add typescript +yarn add + + +Example: Git status check + +git diff --cached +git diff + + +Example: Git log viewing + +git log --oneline +git log + + +Example: Git branch operations + +git checkout -b feature-branch +git checkout + + +Example: Docker build + +docker build -t myapp . +docker build + + +Example: Docker container listing + +docker ps -a +docker ps + + +Example: Cargo testing + +cargo test --release +cargo test + + +Example: Cargo building + +cargo build --release +cargo build + + +Example: Go testing + +go test ./... +go test + + +Example: Go module management + +go mod tidy +go mod + + +Example: Maven clean + +mvn clean compile +mvn clean + + +Example: Maven testing + +mvn test +mvn test + + +Example: Pip package installation + +pip install -r requirements.txt +pip install + + +Example: File listing + +ls -la src/ +ls + + +Example: Directory creation + +mkdir -p build/output +mkdir + + +Example: File copying + +cp config.example.json config.json +cp + + +Example: File moving + +mv old-name.txt new-name.txt +mv + + +Example: Text search + +grep -r "TODO" src/ +grep + + +Example: File search + +find . -name "*.ts" -type f +find + + +Example: File permissions + +chmod +x build.sh +chmod + + +Example: Chained command (no prefix provided for safety) + +npm run build && npm run test ` } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 46da7485ed..4ed82654bc 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -418,6 +418,7 @@ export class Task extends EventEmitter { partial?: boolean, progressStatus?: ToolProgressStatus, isProtected?: boolean, + commandPrefix?: string, ): Promise<{ response: ClineAskResponse; text?: string; images?: string[] }> { // If this Cline instance was aborted by the provider, then the only // thing keeping us alive is a promise still running in the background, @@ -446,6 +447,7 @@ export class Task extends EventEmitter { lastMessage.partial = partial lastMessage.progressStatus = progressStatus lastMessage.isProtected = isProtected + lastMessage.commandPrefix = commandPrefix // TODO: Be more efficient about saving and posting only new // data or one whole message at a time so ignore partial for // saves, and only post parts of partial message instead of @@ -457,7 +459,15 @@ export class Task extends EventEmitter { // state. askTs = Date.now() this.lastMessageTs = askTs - await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial, isProtected }) + await this.addToClineMessages({ + ts: askTs, + type: "ask", + ask: type, + text, + partial, + isProtected, + commandPrefix, + }) throw new Error("Current ask promise was ignored (#2)") } } else { @@ -485,6 +495,7 @@ export class Task extends EventEmitter { lastMessage.partial = false lastMessage.progressStatus = progressStatus lastMessage.isProtected = isProtected + lastMessage.commandPrefix = commandPrefix await this.saveClineMessages() this.updateClineMessage(lastMessage) } else { @@ -494,7 +505,14 @@ export class Task extends EventEmitter { this.askResponseImages = undefined askTs = Date.now() this.lastMessageTs = askTs - await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected }) + await this.addToClineMessages({ + ts: askTs, + type: "ask", + ask: type, + text, + isProtected, + commandPrefix, + }) } } } else { @@ -504,7 +522,7 @@ export class Task extends EventEmitter { this.askResponseImages = undefined askTs = Date.now() this.lastMessageTs = askTs - await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected }) + await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected, commandPrefix }) } await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) diff --git a/src/core/tools/executeCommandTool.ts b/src/core/tools/executeCommandTool.ts index 795beccc06..0d63b9af28 100644 --- a/src/core/tools/executeCommandTool.ts +++ b/src/core/tools/executeCommandTool.ts @@ -51,7 +51,8 @@ export async function executeCommandTool( cline.consecutiveMistakeCount = 0 command = unescapeHtmlEntities(command) // Unescape HTML entities. - const didApprove = await askApproval("command", command) + const prefix = block.params.prefix || "" + const didApprove = await askApproval("command", command, undefined, false, { prefix }) if (!didApprove) { return diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index cac94aa0ce..9ea683385e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -576,6 +576,30 @@ export const webviewMessageHandler = async ( break } + case "alwaysAllowCommand": { + // Add a command prefix to the allowed commands list + const commandPrefix = message.text?.trim() + if (commandPrefix) { + const currentCommands = getGlobalState("allowedCommands") ?? [] + const updatedCommands = [...currentCommands] + + // Only add if not already present + if (!updatedCommands.includes(commandPrefix)) { + updatedCommands.push(commandPrefix) + + await updateGlobalState("allowedCommands", updatedCommands) + + // Also update workspace settings + await vscode.workspace + .getConfiguration(Package.name) + .update("allowedCommands", updatedCommands, vscode.ConfigurationTarget.Global) + + // Update the webview state + await provider.postStateToWebview() + } + } + break + } case "openCustomModesSettings": { const customModesFilePath = await provider.customModesManager.getCustomModesFilePath() diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 7efc97e8c7..5d83533a6a 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -31,6 +31,7 @@ export interface WebviewMessage { | "getListApiConfiguration" | "customInstructions" | "allowedCommands" + | "alwaysAllowCommand" | "alwaysAllowReadOnly" | "alwaysAllowReadOnlyOutsideWorkspace" | "alwaysAllowWrite" diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 0725e2e4d6..1d1bba457b 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -9,6 +9,7 @@ export type AskApproval = ( partialMessage?: string, progressStatus?: ToolProgressStatus, forceApproval?: boolean, + options?: { prefix?: string }, ) => Promise export type HandleError = (action: string, error: Error) => Promise @@ -64,6 +65,7 @@ export const toolParamNames = [ "end_line", "query", "args", + "prefix", ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -79,7 +81,7 @@ export interface ToolUse { export interface ExecuteCommandToolUse extends ToolUse { name: "execute_command" // Pick, "command"> makes "command" required, but Partial<> makes it optional - params: Partial, "command" | "cwd">> + params: Partial, "command" | "cwd" | "prefix">> } export interface ReadFileToolUse extends ToolUse { diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 43824c5902..474a9d6889 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -50,21 +50,55 @@ interface ChatRowProps { onHeightChange: (isTaller: boolean) => void onSuggestionClick?: (answer: string, event?: React.MouseEvent) => void onBatchFileResponse?: (response: { [key: string]: boolean }) => void + alwaysAllowChecked?: boolean + onAlwaysAllowChange?: (checked: boolean, commandPrefix?: string) => void + allowedCommands?: string[] + isAskPending?: boolean } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface ChatRowContentProps extends Omit {} +interface ChatRowContentProps { + message: ClineMessage + lastModifiedMessage?: ClineMessage + isExpanded: boolean + isLast: boolean + isStreaming: boolean + onToggleExpand: (ts: number) => void + onSuggestionClick?: (answer: string, event?: React.MouseEvent) => void + onBatchFileResponse?: (response: { [key: string]: boolean }) => void + commandProps?: { + alwaysAllowChecked?: boolean + onAlwaysAllowChange?: (checked: boolean, commandPrefix?: string) => void + allowedCommands?: string[] + isAskPending?: boolean + } +} const ChatRow = memo( (props: ChatRowProps) => { - const { isLast, onHeightChange, message } = props + const { + isLast, + onHeightChange, + message, + alwaysAllowChecked, + onAlwaysAllowChange, + allowedCommands, + isAskPending, + } = props // Store the previous height to compare with the current height // This allows us to detect changes without causing re-renders const prevHeightRef = useRef(0) const [chatrow, { height }] = useSize(
- +
, ) @@ -99,6 +133,7 @@ export const ChatRowContent = ({ onToggleExpand, onSuggestionClick, onBatchFileResponse, + commandProps, }: ChatRowContentProps) => { const { t } = useTranslation() const { mcpServers, alwaysAllowMcp, currentCheckpoint } = useExtensionState() @@ -1120,6 +1155,13 @@ export const ChatRowContent = ({ text={message.text} icon={icon} title={title} + message={message} + alwaysAllowChecked={commandProps?.alwaysAllowChecked} + onAlwaysAllowChange={(checked: boolean) => + commandProps?.onAlwaysAllowChange?.(checked, message.commandPrefix) + } + allowedCommands={commandProps?.allowedCommands} + isAskPending={commandProps?.isAskPending} /> ) case "use_mcp_server": diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index a4f18c870c..0fd1ea99be 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -140,6 +140,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction(undefined) const [secondaryButtonText, setSecondaryButtonText] = useState(undefined) const [didClickCancel, setDidClickCancel] = useState(false) + const [alwaysAllowChecked, setAlwaysAllowChecked] = useState(false) + const [commandPrefixToAllow, setCommandPrefixToAllow] = useState(undefined) const virtuosoRef = useRef(null) const [expandedRows, setExpandedRows] = useState>({}) const prevExpandedRowsRef = useRef>() @@ -313,6 +315,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0)) { vscode.postMessage({ @@ -574,6 +586,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + setAlwaysAllowChecked(checked) + setCommandPrefixToAllow(checked ? commandPrefix : undefined) + + // Handle immediate addition/removal of command from allowed list + if (commandPrefix) { + if (checked) { + // Add command - this is handled by the CommandExecution component + // No need to do anything here as the message is already sent + } else { + // Remove command from allowed list + const currentCommands = allowedCommands || [] + const updatedCommands = currentCommands.filter((cmd) => cmd !== commandPrefix) + vscode.postMessage({ + type: "allowedCommands", + commands: updatedCommands, + }) + } + } + }} + allowedCommands={allowedCommands} + isAskPending={clineAsk === "command"} /> ) }, @@ -1253,6 +1290,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction ) : ( -
- {primaryButtonText && !isStreaming && ( - +
+ {primaryButtonText && !isStreaming && ( + - handlePrimaryButtonClick(inputValue, selectedImages)}> - {primaryButtonText} - - - )} - {(secondaryButtonText || isStreaming) && ( - - handleSecondaryButtonClick(inputValue, selectedImages)}> - {isStreaming ? t("chat:cancel.title") : secondaryButtonText} - - - )} + t("chat:proceedAnyways.title") + ? t("chat:proceedAnyways.tooltip") + : primaryButtonText === + t("chat:proceedWhileRunning.title") + ? t("chat:proceedWhileRunning.tooltip") + : undefined + }> + handlePrimaryButtonClick(inputValue, selectedImages)}> + {primaryButtonText} + + + )} + {(secondaryButtonText || isStreaming) && ( + + handleSecondaryButtonClick(inputValue, selectedImages)}> + {isStreaming ? t("chat:cancel.title") : secondaryButtonText} + + + )} +
)} diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 8c92ec7e7b..e54ab6eb38 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -1,8 +1,9 @@ import { useCallback, useState, memo, useMemo } from "react" import { useEvent } from "react-use" import { ChevronDown, Skull } from "lucide-react" +import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" -import { CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/types" +import { CommandExecutionStatus, commandExecutionStatusSchema, ClineMessage } from "@roo-code/types" import { ExtensionMessage } from "@roo/ExtensionMessage" import { safeJsonParse } from "@roo/safeJsonParse" @@ -19,9 +20,24 @@ interface CommandExecutionProps { text?: string icon?: JSX.Element | null title?: JSX.Element | null + message?: ClineMessage + onAlwaysAllowChange?: (checked: boolean, commandPrefix?: string) => void + alwaysAllowChecked?: boolean + allowedCommands?: string[] + isAskPending?: boolean } -export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => { +export const CommandExecution = ({ + executionId, + text, + icon, + title, + message, + onAlwaysAllowChange, + alwaysAllowChecked = false, + allowedCommands = [], + isAskPending = false, +}: CommandExecutionProps) => { const { terminalShellIntegrationDisabled = false } = useExtensionState() const { command, output: parsedOutput } = useMemo(() => parseCommandAndOutput(text), [text]) @@ -31,6 +47,8 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec const [isExpanded, setIsExpanded] = useState(terminalShellIntegrationDisabled) const [streamingOutput, setStreamingOutput] = useState("") const [status, setStatus] = useState(null) + // Track if the user has clicked "always allow" for this command to optimistically hide the checkbox + const [hasClickedAlwaysAllow, setHasClickedAlwaysAllow] = useState(false) // The command's output can either come from the text associated with the // task message (this is the case for completed commands) or from the @@ -82,6 +100,57 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
+ {message?.commandPrefix && + onAlwaysAllowChange && + isAskPending && + !allowedCommands.includes(message.commandPrefix) && + !hasClickedAlwaysAllow && ( +
+ { + const checked = (e.target as HTMLInputElement).checked + const commandPrefix = message.commandPrefix + + // Send message immediately when checkbox is toggled + if (checked && commandPrefix) { + vscode.postMessage({ + type: "alwaysAllowCommand", + text: commandPrefix, + }) + // Optimistically hide the checkbox + setHasClickedAlwaysAllow(true) + } + + // Also call the callback for UI state management + // The callback will handle removal when unchecked + onAlwaysAllowChange(checked, commandPrefix) + }} + /> + +
+ )} {status?.status === "started" && (
@@ -120,7 +189,6 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
-