diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index af1d72c6a54..6a9e0cd5f97 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -1,7 +1,7 @@ import { useCallback, useState, memo, useMemo } from "react" import { useEvent } from "react-use" import { t } from "i18next" -import { ChevronDown, OctagonX } from "lucide-react" +import { ChevronDown, OctagonX, ShieldAlert } from "lucide-react" import { type ExtensionMessage, type CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/types" @@ -11,6 +11,7 @@ import { parseCommand } from "@roo/parse-command" import { vscode } from "@src/utils/vscode" import { extractPatternsFromCommand } from "@src/utils/command-parser" +import { getDeniedSubcommands } from "@src/utils/command-denied" import { useExtensionState } from "@src/context/ExtensionStateContext" import { cn } from "@src/lib/utils" @@ -54,6 +55,12 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec // streaming output (this is the case for running commands). const output = streamingOutput || parsedOutput + // Identify denied sub-commands within the full command + const deniedSubcommands = useMemo( + () => getDeniedSubcommands(command, allowedCommands, deniedCommands), + [command, allowedCommands, deniedCommands], + ) + // Extract command patterns from the actual command that was executed const commandPatterns = useMemo(() => { // First get all individual commands (including subshell commands) using parseCommand @@ -202,6 +209,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
+ {deniedSubcommands.length > 0 && }
{command && command.trim() && ( @@ -232,6 +240,36 @@ const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean; const OutputContainer = memo(OutputContainerInternal) +const DeniedCommandsBanner = ({ deniedSubcommands }: { deniedSubcommands: string[] }) => ( +
+ +
+ {t("chat:commandExecution.deniedCommandDetected")}: + {deniedSubcommands.map((cmd, i) => ( + + {cmd} + + ))} +
+
+) + const parseCommandAndOutput = (text: string | undefined) => { if (!text) { return { command: "", output: "" } diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx index f40987d269d..c440c80f680 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -605,4 +605,55 @@ Output: expect(terminalOutput).toHaveTextContent("0 total") }) }) + + describe("denied commands banner", () => { + it("should show denied commands banner when command matches deny list", () => { + render( + + + , + ) + + const banner = screen.getByTestId("denied-commands-banner") + expect(banner).toBeInTheDocument() + expect(banner.textContent).toContain("rm -rf /") + }) + + it("should not show denied commands banner when command is allowed", () => { + render( + + + , + ) + + expect(screen.queryByTestId("denied-commands-banner")).not.toBeInTheDocument() + }) + + it("should show denied commands in chained commands", () => { + render( + + + , + ) + + const banner = screen.getByTestId("denied-commands-banner") + expect(banner).toBeInTheDocument() + expect(banner.textContent).toContain("rm -rf /") + }) + + it("should not show banner when deniedCommands list is empty", () => { + const stateWithNoDenied = { + ...mockExtensionState, + deniedCommands: [], + } + + render( + + + , + ) + + expect(screen.queryByTestId("denied-commands-banner")).not.toBeInTheDocument() + }) + }) }) diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 6f1badac1f1..eb6bf4a17d9 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -283,7 +283,8 @@ "expandOutput": "Expand output", "collapseOutput": "Collapse output", "expandManagement": "Expand command management section", - "collapseManagement": "Collapse command management section" + "collapseManagement": "Collapse command management section", + "deniedCommandDetected": "Denied command detected" }, "response": "Response", "arguments": "Arguments", diff --git a/webview-ui/src/utils/__tests__/command-denied.spec.ts b/webview-ui/src/utils/__tests__/command-denied.spec.ts new file mode 100644 index 00000000000..4a38f6f35df --- /dev/null +++ b/webview-ui/src/utils/__tests__/command-denied.spec.ts @@ -0,0 +1,74 @@ +// pnpm --filter @roo-code/vscode-webview test src/utils/__tests__/command-denied.spec.ts + +import { getDeniedSubcommands } from "../command-denied" + +vi.mock("@roo/parse-command", () => ({ + parseCommand: (command: string) => { + if (!command?.trim()) return [] + // Simple split by &&, ||, ;, | for testing + return command + .split(/\s*(?:&&|\|\||;|\|)\s*/) + .map((c) => c.trim()) + .filter(Boolean) + }, +})) + +describe("getDeniedSubcommands", () => { + it("should return empty array when command is empty", () => { + expect(getDeniedSubcommands("", ["npm"], ["rm"])).toEqual([]) + }) + + it("should return empty array when deniedCommands is empty", () => { + expect(getDeniedSubcommands("rm -rf /", ["npm"], [])).toEqual([]) + }) + + it("should return empty array when no sub-commands are denied", () => { + expect(getDeniedSubcommands("npm install", ["npm"], ["rm"])).toEqual([]) + }) + + it("should identify a single denied command", () => { + expect(getDeniedSubcommands("rm -rf /", ["npm"], ["rm"])).toEqual(["rm -rf /"]) + }) + + it("should identify denied commands in chained commands", () => { + const result = getDeniedSubcommands("npm install && rm -rf /", ["npm"], ["rm"]) + expect(result).toEqual(["rm -rf /"]) + }) + + it("should identify multiple denied commands", () => { + const result = getDeniedSubcommands("rm file.txt && npm install && rm -rf /tmp", ["npm"], ["rm"]) + expect(result).toEqual(["rm file.txt", "rm -rf /tmp"]) + }) + + it("should respect longest prefix match - allow wins when more specific", () => { + // "rm -i" is allowed and more specific than denied "rm" + const result = getDeniedSubcommands("rm -i file.txt", ["rm -i"], ["rm"]) + expect(result).toEqual([]) + }) + + it("should respect longest prefix match - deny wins when more specific", () => { + // "git push" is denied and more specific than allowed "git" + const result = getDeniedSubcommands("git push origin main", ["git"], ["git push"]) + expect(result).toEqual(["git push origin main"]) + }) + + it("should respect longest prefix match - deny wins when equal length", () => { + const result = getDeniedSubcommands("rm file.txt", ["rm"], ["rm"]) + expect(result).toEqual(["rm file.txt"]) + }) + + it("should handle commands with no allowed list matches", () => { + const result = getDeniedSubcommands("rm -rf /", [], ["rm"]) + expect(result).toEqual(["rm -rf /"]) + }) + + it("should be case-insensitive", () => { + const result = getDeniedSubcommands("RM -rf /", ["npm"], ["rm"]) + expect(result).toEqual(["RM -rf /"]) + }) + + it("should handle mixed allowed and denied in chain", () => { + const result = getDeniedSubcommands("git status && rm file && npm test", ["git", "npm"], ["rm"]) + expect(result).toEqual(["rm file"]) + }) +}) diff --git a/webview-ui/src/utils/command-denied.ts b/webview-ui/src/utils/command-denied.ts new file mode 100644 index 00000000000..bbb21de3bff --- /dev/null +++ b/webview-ui/src/utils/command-denied.ts @@ -0,0 +1,78 @@ +import { parseCommand } from "@roo/parse-command" + +/** + * Find the longest matching prefix from a list of prefixes for a given command. + * Case-insensitive prefix matching with wildcard support. + * + * This mirrors the logic in `src/core/auto-approval/commands.ts` so the webview + * can independently determine which sub-commands are denied. + */ +function findLongestPrefixMatch(command: string, prefixes: string[]): string | null { + if (!command || !prefixes?.length) { + return null + } + + const trimmedCommand = command.trim().toLowerCase() + let longestMatch: string | null = null + + for (const prefix of prefixes) { + const lowerPrefix = prefix.toLowerCase() + if (lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)) { + if (!longestMatch || lowerPrefix.length > longestMatch.length) { + longestMatch = lowerPrefix + } + } + } + + return longestMatch +} + +/** + * Check if a single sub-command is denied based on the longest prefix match rule. + * A command is considered denied when the deny list has a matching prefix that is + * at least as long as any matching allow list prefix. + */ +function isSubcommandDenied(command: string, allowedCommands: string[], deniedCommands: string[]): boolean { + if (!command?.trim() || !deniedCommands?.length) { + return false + } + + const cmdWithoutRedirection = command.replace(/\d*>&\d*/, "").trim() + const longestDeniedMatch = findLongestPrefixMatch(cmdWithoutRedirection, deniedCommands) + + if (!longestDeniedMatch) { + return false + } + + const longestAllowedMatch = findLongestPrefixMatch(cmdWithoutRedirection, allowedCommands || []) + + if (!longestAllowedMatch) { + return true + } + + // Deny list wins when its match is longer or equal + return longestDeniedMatch.length >= longestAllowedMatch.length +} + +/** + * Get the list of denied sub-commands from a full command string. + * Parses the command into sub-commands (splitting by &&, ||, ;, |, etc.) + * and returns the ones that match the deny list. + * + * @param command - Full command string (may contain chained commands) + * @param allowedCommands - List of allowed command prefixes + * @param deniedCommands - List of denied command prefixes + * @returns Array of sub-command strings that are denied + */ +export function getDeniedSubcommands(command: string, allowedCommands: string[], deniedCommands: string[]): string[] { + if (!command?.trim() || !deniedCommands?.length) { + return [] + } + + const subCommands = parseCommand(command) + + return subCommands.filter((cmd) => { + const trimmed = cmd.trim() + return trimmed && isSubcommandDenied(trimmed, allowedCommands, deniedCommands) + }) +}