Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit ff21dac

Browse files
committed
feat: highlight denied commands in command execution code block
When a command is blocked by the denied commands list, a yellow warning banner now appears below the code block showing which specific sub-commands triggered the denial. This helps users understand why a command is asking for approval when all other commands in the block appear to be allowed. - Add getDeniedSubcommands utility to identify denied sub-commands - Add DeniedCommandsBanner component with yellow warning styling - Add i18n string for denied command detection message - Add tests for utility and component integration
1 parent ad25634 commit ff21dac

5 files changed

Lines changed: 244 additions & 2 deletions

File tree

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

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useCallback, useState, memo, useMemo } from "react"
22
import { useEvent } from "react-use"
33
import { t } from "i18next"
4-
import { ChevronDown, OctagonX } from "lucide-react"
4+
import { ChevronDown, OctagonX, ShieldAlert } from "lucide-react"
55

66
import { type ExtensionMessage, type CommandExecutionStatus, commandExecutionStatusSchema } from "@roo-code/types"
77

@@ -11,6 +11,7 @@ import { parseCommand } from "@roo/parse-command"
1111

1212
import { vscode } from "@src/utils/vscode"
1313
import { extractPatternsFromCommand } from "@src/utils/command-parser"
14+
import { getDeniedSubcommands } from "@src/utils/command-denied"
1415
import { useExtensionState } from "@src/context/ExtensionStateContext"
1516
import { cn } from "@src/lib/utils"
1617

@@ -54,6 +55,12 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
5455
// streaming output (this is the case for running commands).
5556
const output = streamingOutput || parsedOutput
5657

58+
// Identify denied sub-commands within the full command
59+
const deniedSubcommands = useMemo(
60+
() => getDeniedSubcommands(command, allowedCommands, deniedCommands),
61+
[command, allowedCommands, deniedCommands],
62+
)
63+
5764
// Extract command patterns from the actual command that was executed
5865
const commandPatterns = useMemo<CommandPattern[]>(() => {
5966
// First get all individual commands (including subshell commands) using parseCommand
@@ -202,6 +209,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec
202209
<div className="bg-vscode-editor-background border border-vscode-border rounded-xs ml-6 mt-2">
203210
<div className="p-2">
204211
<CodeBlock source={command} language="shell" />
212+
{deniedSubcommands.length > 0 && <DeniedCommandsBanner deniedSubcommands={deniedSubcommands} />}
205213
<OutputContainer isExpanded={isExpanded} output={output} />
206214
</div>
207215
{command && command.trim() && (
@@ -232,6 +240,36 @@ const OutputContainerInternal = ({ isExpanded, output }: { isExpanded: boolean;
232240

233241
const OutputContainer = memo(OutputContainerInternal)
234242

243+
const DeniedCommandsBanner = ({ deniedSubcommands }: { deniedSubcommands: string[] }) => (
244+
<div
245+
className="flex items-start gap-1.5 mt-2 px-2 py-1.5 rounded text-xs border"
246+
style={{
247+
backgroundColor: "var(--vscode-inputValidation-warningBackground, rgba(255, 204, 0, 0.1))",
248+
borderColor: "var(--vscode-inputValidation-warningBorder, #cca700)",
249+
color: "var(--vscode-inputValidation-warningForeground, var(--vscode-foreground))",
250+
}}
251+
data-testid="denied-commands-banner">
252+
<ShieldAlert
253+
className="size-3.5 shrink-0 mt-0.5"
254+
style={{ color: "var(--vscode-inputValidation-warningBorder, #cca700)" }}
255+
/>
256+
<div>
257+
<span>{t("chat:commandExecution.deniedCommandDetected")}: </span>
258+
{deniedSubcommands.map((cmd, i) => (
259+
<code
260+
key={i}
261+
className="px-1 py-0.5 rounded font-mono"
262+
style={{
263+
backgroundColor: "rgba(255, 204, 0, 0.15)",
264+
color: "var(--vscode-inputValidation-warningBorder, #cca700)",
265+
}}>
266+
{cmd}
267+
</code>
268+
))}
269+
</div>
270+
</div>
271+
)
272+
235273
const parseCommandAndOutput = (text: string | undefined) => {
236274
if (!text) {
237275
return { command: "", output: "" }

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,4 +605,55 @@ Output:
605605
expect(terminalOutput).toHaveTextContent("0 total")
606606
})
607607
})
608+
609+
describe("denied commands banner", () => {
610+
it("should show denied commands banner when command matches deny list", () => {
611+
render(
612+
<ExtensionStateWrapper>
613+
<CommandExecution executionId="test-denied-1" text="rm -rf /" />
614+
</ExtensionStateWrapper>,
615+
)
616+
617+
const banner = screen.getByTestId("denied-commands-banner")
618+
expect(banner).toBeInTheDocument()
619+
expect(banner.textContent).toContain("rm -rf /")
620+
})
621+
622+
it("should not show denied commands banner when command is allowed", () => {
623+
render(
624+
<ExtensionStateWrapper>
625+
<CommandExecution executionId="test-denied-2" text="npm install" />
626+
</ExtensionStateWrapper>,
627+
)
628+
629+
expect(screen.queryByTestId("denied-commands-banner")).not.toBeInTheDocument()
630+
})
631+
632+
it("should show denied commands in chained commands", () => {
633+
render(
634+
<ExtensionStateWrapper>
635+
<CommandExecution executionId="test-denied-3" text="npm install && rm -rf /" />
636+
</ExtensionStateWrapper>,
637+
)
638+
639+
const banner = screen.getByTestId("denied-commands-banner")
640+
expect(banner).toBeInTheDocument()
641+
expect(banner.textContent).toContain("rm -rf /")
642+
})
643+
644+
it("should not show banner when deniedCommands list is empty", () => {
645+
const stateWithNoDenied = {
646+
...mockExtensionState,
647+
deniedCommands: [],
648+
}
649+
650+
render(
651+
<ExtensionStateContext.Provider value={stateWithNoDenied as any}>
652+
<CommandExecution executionId="test-denied-4" text="rm -rf /" />
653+
</ExtensionStateContext.Provider>,
654+
)
655+
656+
expect(screen.queryByTestId("denied-commands-banner")).not.toBeInTheDocument()
657+
})
658+
})
608659
})

webview-ui/src/i18n/locales/en/chat.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,8 @@
283283
"expandOutput": "Expand output",
284284
"collapseOutput": "Collapse output",
285285
"expandManagement": "Expand command management section",
286-
"collapseManagement": "Collapse command management section"
286+
"collapseManagement": "Collapse command management section",
287+
"deniedCommandDetected": "Denied command detected"
287288
},
288289
"response": "Response",
289290
"arguments": "Arguments",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// pnpm --filter @roo-code/vscode-webview test src/utils/__tests__/command-denied.spec.ts
2+
3+
import { getDeniedSubcommands } from "../command-denied"
4+
5+
vi.mock("@roo/parse-command", () => ({
6+
parseCommand: (command: string) => {
7+
if (!command?.trim()) return []
8+
// Simple split by &&, ||, ;, | for testing
9+
return command
10+
.split(/\s*(?:&&|\|\||;|\|)\s*/)
11+
.map((c) => c.trim())
12+
.filter(Boolean)
13+
},
14+
}))
15+
16+
describe("getDeniedSubcommands", () => {
17+
it("should return empty array when command is empty", () => {
18+
expect(getDeniedSubcommands("", ["npm"], ["rm"])).toEqual([])
19+
})
20+
21+
it("should return empty array when deniedCommands is empty", () => {
22+
expect(getDeniedSubcommands("rm -rf /", ["npm"], [])).toEqual([])
23+
})
24+
25+
it("should return empty array when no sub-commands are denied", () => {
26+
expect(getDeniedSubcommands("npm install", ["npm"], ["rm"])).toEqual([])
27+
})
28+
29+
it("should identify a single denied command", () => {
30+
expect(getDeniedSubcommands("rm -rf /", ["npm"], ["rm"])).toEqual(["rm -rf /"])
31+
})
32+
33+
it("should identify denied commands in chained commands", () => {
34+
const result = getDeniedSubcommands("npm install && rm -rf /", ["npm"], ["rm"])
35+
expect(result).toEqual(["rm -rf /"])
36+
})
37+
38+
it("should identify multiple denied commands", () => {
39+
const result = getDeniedSubcommands("rm file.txt && npm install && rm -rf /tmp", ["npm"], ["rm"])
40+
expect(result).toEqual(["rm file.txt", "rm -rf /tmp"])
41+
})
42+
43+
it("should respect longest prefix match - allow wins when more specific", () => {
44+
// "rm -i" is allowed and more specific than denied "rm"
45+
const result = getDeniedSubcommands("rm -i file.txt", ["rm -i"], ["rm"])
46+
expect(result).toEqual([])
47+
})
48+
49+
it("should respect longest prefix match - deny wins when more specific", () => {
50+
// "git push" is denied and more specific than allowed "git"
51+
const result = getDeniedSubcommands("git push origin main", ["git"], ["git push"])
52+
expect(result).toEqual(["git push origin main"])
53+
})
54+
55+
it("should respect longest prefix match - deny wins when equal length", () => {
56+
const result = getDeniedSubcommands("rm file.txt", ["rm"], ["rm"])
57+
expect(result).toEqual(["rm file.txt"])
58+
})
59+
60+
it("should handle commands with no allowed list matches", () => {
61+
const result = getDeniedSubcommands("rm -rf /", [], ["rm"])
62+
expect(result).toEqual(["rm -rf /"])
63+
})
64+
65+
it("should be case-insensitive", () => {
66+
const result = getDeniedSubcommands("RM -rf /", ["npm"], ["rm"])
67+
expect(result).toEqual(["RM -rf /"])
68+
})
69+
70+
it("should handle mixed allowed and denied in chain", () => {
71+
const result = getDeniedSubcommands("git status && rm file && npm test", ["git", "npm"], ["rm"])
72+
expect(result).toEqual(["rm file"])
73+
})
74+
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { parseCommand } from "@roo/parse-command"
2+
3+
/**
4+
* Find the longest matching prefix from a list of prefixes for a given command.
5+
* Case-insensitive prefix matching with wildcard support.
6+
*
7+
* This mirrors the logic in `src/core/auto-approval/commands.ts` so the webview
8+
* can independently determine which sub-commands are denied.
9+
*/
10+
function findLongestPrefixMatch(command: string, prefixes: string[]): string | null {
11+
if (!command || !prefixes?.length) {
12+
return null
13+
}
14+
15+
const trimmedCommand = command.trim().toLowerCase()
16+
let longestMatch: string | null = null
17+
18+
for (const prefix of prefixes) {
19+
const lowerPrefix = prefix.toLowerCase()
20+
if (lowerPrefix === "*" || trimmedCommand.startsWith(lowerPrefix)) {
21+
if (!longestMatch || lowerPrefix.length > longestMatch.length) {
22+
longestMatch = lowerPrefix
23+
}
24+
}
25+
}
26+
27+
return longestMatch
28+
}
29+
30+
/**
31+
* Check if a single sub-command is denied based on the longest prefix match rule.
32+
* A command is considered denied when the deny list has a matching prefix that is
33+
* at least as long as any matching allow list prefix.
34+
*/
35+
function isSubcommandDenied(command: string, allowedCommands: string[], deniedCommands: string[]): boolean {
36+
if (!command?.trim() || !deniedCommands?.length) {
37+
return false
38+
}
39+
40+
const cmdWithoutRedirection = command.replace(/\d*>&\d*/, "").trim()
41+
const longestDeniedMatch = findLongestPrefixMatch(cmdWithoutRedirection, deniedCommands)
42+
43+
if (!longestDeniedMatch) {
44+
return false
45+
}
46+
47+
const longestAllowedMatch = findLongestPrefixMatch(cmdWithoutRedirection, allowedCommands || [])
48+
49+
if (!longestAllowedMatch) {
50+
return true
51+
}
52+
53+
// Deny list wins when its match is longer or equal
54+
return longestDeniedMatch.length >= longestAllowedMatch.length
55+
}
56+
57+
/**
58+
* Get the list of denied sub-commands from a full command string.
59+
* Parses the command into sub-commands (splitting by &&, ||, ;, |, etc.)
60+
* and returns the ones that match the deny list.
61+
*
62+
* @param command - Full command string (may contain chained commands)
63+
* @param allowedCommands - List of allowed command prefixes
64+
* @param deniedCommands - List of denied command prefixes
65+
* @returns Array of sub-command strings that are denied
66+
*/
67+
export function getDeniedSubcommands(command: string, allowedCommands: string[], deniedCommands: string[]): string[] {
68+
if (!command?.trim() || !deniedCommands?.length) {
69+
return []
70+
}
71+
72+
const subCommands = parseCommand(command)
73+
74+
return subCommands.filter((cmd) => {
75+
const trimmed = cmd.trim()
76+
return trimmed && isSubcommandDenied(trimmed, allowedCommands, deniedCommands)
77+
})
78+
}

0 commit comments

Comments
 (0)