Skip to content

Commit ac4568c

Browse files
committed
Add blacklisted commands feature to settings and command validation
1 parent 0f22eab commit ac4568c

File tree

11 files changed

+196
-9
lines changed

11 files changed

+196
-9
lines changed

packages/types/src/global-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const globalSettingsSchema = z.object({
4545
alwaysAllowSubtasks: z.boolean().optional(),
4646
alwaysAllowExecute: z.boolean().optional(),
4747
allowedCommands: z.array(z.string()).optional(),
48+
blacklistedCommands: z.array(z.string()).optional(),
4849
allowedMaxRequests: z.number().nullish(),
4950
autoCondenseContext: z.boolean().optional(),
5051
autoCondenseContextPercent: z.number().optional(),
@@ -131,6 +132,7 @@ export const GLOBAL_SETTINGS_KEYS = keysOf<GlobalSettings>()([
131132
"alwaysAllowSubtasks",
132133
"alwaysAllowExecute",
133134
"allowedCommands",
135+
"blacklistedCommands",
134136
"allowedMaxRequests",
135137
"autoCondenseContext",
136138
"autoCondenseContextPercent",

src/core/webview/webviewMessageHandler.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,15 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
444444
.getConfiguration(Package.name)
445445
.update("allowedCommands", message.commands, vscode.ConfigurationTarget.Global)
446446

447+
break
448+
case "blacklistedCommands":
449+
await provider.context.globalState.update("blacklistedCommands", message.commands)
450+
451+
// Also update workspace settings.
452+
await vscode.workspace
453+
.getConfiguration(Package.name)
454+
.update("blacklistedCommands", message.commands, vscode.ConfigurationTarget.Global)
455+
447456
break
448457
case "openCustomModesSettings": {
449458
const customModesFilePath = await provider.customModesManager.getCustomModesFilePath()

src/shared/ExtensionMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ export type ExtensionState = Pick<
138138
| "alwaysAllowSubtasks"
139139
| "alwaysAllowExecute"
140140
| "allowedCommands"
141+
| "blacklistedCommands"
141142
| "allowedMaxRequests"
142143
| "browserToolEnabled"
143144
| "browserViewportSize"

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface WebviewMessage {
2323
| "getListApiConfiguration"
2424
| "customInstructions"
2525
| "allowedCommands"
26+
| "blacklistedCommands"
2627
| "alwaysAllowReadOnly"
2728
| "alwaysAllowReadOnlyOutsideWorkspace"
2829
| "alwaysAllowWrite"

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
8181
alwaysAllowExecute,
8282
alwaysAllowMcp,
8383
allowedCommands,
84+
blacklistedCommands,
8485
writeDelayMs,
8586
mode,
8687
setMode,
@@ -840,9 +841,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
840841
const isAllowedCommand = useCallback(
841842
(message: ClineMessage | undefined): boolean => {
842843
if (message?.type !== "ask") return false
843-
return validateCommand(message.text || "", allowedCommands || [])
844+
return validateCommand(message.text || "", allowedCommands || [], blacklistedCommands || [])
844845
},
845-
[allowedCommands],
846+
[allowedCommands, blacklistedCommands],
846847
)
847848

848849
const isAutoApproved = useCallback(

webview-ui/src/components/settings/AutoApproveSettings.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
2525
alwaysAllowSubtasks?: boolean
2626
alwaysAllowExecute?: boolean
2727
allowedCommands?: string[]
28+
blacklistedCommands?: string[]
2829
setCachedStateField: SetCachedStateField<
2930
| "alwaysAllowReadOnly"
3031
| "alwaysAllowReadOnlyOutsideWorkspace"
@@ -39,6 +40,7 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
3940
| "alwaysAllowSubtasks"
4041
| "alwaysAllowExecute"
4142
| "allowedCommands"
43+
| "blacklistedCommands"
4244
>
4345
}
4446

@@ -56,11 +58,13 @@ export const AutoApproveSettings = ({
5658
alwaysAllowSubtasks,
5759
alwaysAllowExecute,
5860
allowedCommands,
61+
blacklistedCommands,
5962
setCachedStateField,
6063
...props
6164
}: AutoApproveSettingsProps) => {
6265
const { t } = useAppTranslation()
6366
const [commandInput, setCommandInput] = useState("")
67+
const [blacklistCommandInput, setBlacklistCommandInput] = useState("")
6468

6569
const handleAddCommand = () => {
6670
const currentCommands = allowedCommands ?? []
@@ -73,6 +77,17 @@ export const AutoApproveSettings = ({
7377
}
7478
}
7579

80+
const handleAddBlacklistCommand = () => {
81+
const currentBlacklistedCommands = blacklistedCommands ?? []
82+
83+
if (blacklistCommandInput && !currentBlacklistedCommands.includes(blacklistCommandInput)) {
84+
const newBlacklistedCommands = [...currentBlacklistedCommands, blacklistCommandInput]
85+
setCachedStateField("blacklistedCommands", newBlacklistedCommands)
86+
setBlacklistCommandInput("")
87+
vscode.postMessage({ type: "blacklistedCommands", commands: newBlacklistedCommands })
88+
}
89+
}
90+
7691
return (
7792
<div {...props}>
7893
<SectionHeader description={t("settings:autoApprove.description")}>
@@ -239,6 +254,61 @@ export const AutoApproveSettings = ({
239254
</Button>
240255
))}
241256
</div>
257+
258+
<div className="mt-4">
259+
<label className="block font-medium mb-1" data-testid="blacklisted-commands-heading">
260+
{t("settings:autoApprove.execute.blacklistedCommands")}
261+
</label>
262+
<div className="text-vscode-descriptionForeground text-sm mt-1">
263+
{t("settings:autoApprove.execute.blacklistedCommandsDescription")}
264+
</div>
265+
</div>
266+
267+
<div className="flex gap-2">
268+
<Input
269+
value={blacklistCommandInput}
270+
onChange={(e: any) => setBlacklistCommandInput(e.target.value)}
271+
onKeyDown={(e: any) => {
272+
if (e.key === "Enter") {
273+
e.preventDefault()
274+
handleAddBlacklistCommand()
275+
}
276+
}}
277+
placeholder={t("settings:autoApprove.execute.blacklistCommandPlaceholder")}
278+
className="grow"
279+
data-testid="blacklist-command-input"
280+
/>
281+
<Button
282+
className="h-8"
283+
onClick={handleAddBlacklistCommand}
284+
data-testid="add-blacklist-command-button">
285+
{t("settings:autoApprove.execute.addBlacklistButton")}
286+
</Button>
287+
</div>
288+
289+
<div className="flex flex-wrap gap-2">
290+
{(blacklistedCommands ?? []).map((cmd, index) => (
291+
<Button
292+
key={index}
293+
variant="destructive"
294+
data-testid={`remove-blacklist-command-${index}`}
295+
onClick={() => {
296+
const newBlacklistedCommands = (blacklistedCommands ?? []).filter(
297+
(_, i) => i !== index,
298+
)
299+
setCachedStateField("blacklistedCommands", newBlacklistedCommands)
300+
vscode.postMessage({
301+
type: "blacklistedCommands",
302+
commands: newBlacklistedCommands,
303+
})
304+
}}>
305+
<div className="flex flex-row items-center gap-1">
306+
<div>{cmd}</div>
307+
<X className="text-foreground scale-75" />
308+
</div>
309+
</Button>
310+
))}
311+
</div>
242312
</div>
243313
)}
244314
</Section>

webview-ui/src/components/settings/SettingsView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
122122
alwaysAllowReadOnly,
123123
alwaysAllowReadOnlyOutsideWorkspace,
124124
allowedCommands,
125+
blacklistedCommands,
125126
allowedMaxRequests,
126127
language,
127128
alwaysAllowBrowser,
@@ -256,6 +257,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
256257
vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser })
257258
vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp })
258259
vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
260+
vscode.postMessage({ type: "blacklistedCommands", commands: blacklistedCommands ?? [] })
259261
vscode.postMessage({ type: "allowedMaxRequests", value: allowedMaxRequests ?? undefined })
260262
vscode.postMessage({ type: "autoCondenseContext", bool: autoCondenseContext })
261263
vscode.postMessage({ type: "autoCondenseContextPercent", value: autoCondenseContextPercent })
@@ -578,6 +580,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
578580
alwaysAllowSubtasks={alwaysAllowSubtasks}
579581
alwaysAllowExecute={alwaysAllowExecute}
580582
allowedCommands={allowedCommands}
583+
blacklistedCommands={blacklistedCommands}
581584
setCachedStateField={setCachedStateField}
582585
/>
583586
)}

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export interface ExtensionStateContextType extends ExtensionState {
5454
setShowRooIgnoredFiles: (value: boolean) => void
5555
setShowAnnouncement: (value: boolean) => void
5656
setAllowedCommands: (value: string[]) => void
57+
setBlacklistedCommands: (value: string[]) => void
5758
setAllowedMaxRequests: (value: number | undefined) => void
5859
setSoundEnabled: (value: boolean) => void
5960
setSoundVolume: (value: number) => void
@@ -154,6 +155,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
154155
taskHistory: [],
155156
shouldShowAnnouncement: false,
156157
allowedCommands: [],
158+
blacklistedCommands: [],
157159
soundEnabled: false,
158160
soundVolume: 0.5,
159161
ttsEnabled: false,
@@ -332,6 +334,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
332334
setAlwaysAllowSubtasks: (value) => setState((prevState) => ({ ...prevState, alwaysAllowSubtasks: value })),
333335
setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })),
334336
setAllowedCommands: (value) => setState((prevState) => ({ ...prevState, allowedCommands: value })),
337+
setBlacklistedCommands: (value) => setState((prevState) => ({ ...prevState, blacklistedCommands: value })),
335338
setAllowedMaxRequests: (value) => setState((prevState) => ({ ...prevState, allowedMaxRequests: value })),
336339
setSoundEnabled: (value) => setState((prevState) => ({ ...prevState, soundEnabled: value })),
337340
setSoundVolume: (value) => setState((prevState) => ({ ...prevState, soundVolume: value })),

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,11 @@
106106
"allowedCommands": "Allowed Auto-Execute Commands",
107107
"allowedCommandsDescription": "Command prefixes that can be auto-executed when \"Always approve execute operations\" is enabled. Add * to allow all commands (use with caution).",
108108
"commandPlaceholder": "Enter command prefix (e.g., 'git ')",
109-
"addButton": "Add"
109+
"addButton": "Add",
110+
"blacklistedCommands": "Blacklisted Commands",
111+
"blacklistedCommandsDescription": "Command prefixes that will be blocked from execution even when \"Always approve execute operations\" is enabled. These commands will always require manual approval.",
112+
"blacklistCommandPlaceholder": "Enter command prefix to block (e.g., 'rm ')",
113+
"addBlacklistButton": "Add to Blacklist"
110114
},
111115
"apiRequestLimit": {
112116
"title": "Max Requests",

webview-ui/src/utils/__tests__/command-validation.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
// npx jest src/utils/__tests__/command-validation.test.ts
44

5-
import { parseCommand, isAllowedSingleCommand, validateCommand } from "../command-validation"
5+
import {
6+
parseCommand,
7+
isAllowedSingleCommand,
8+
isBlacklistedSingleCommand,
9+
validateCommand,
10+
} from "../command-validation"
611

712
describe("Command Validation", () => {
813
describe("parseCommand", () => {
@@ -68,6 +73,33 @@ describe("Command Validation", () => {
6873
})
6974
})
7075

76+
describe("isBlacklistedSingleCommand", () => {
77+
const blacklistedCommands = ["rm ", "sudo ", "del "]
78+
79+
it("matches blacklisted commands case-insensitively", () => {
80+
expect(isBlacklistedSingleCommand("RM -rf /", blacklistedCommands)).toBe(true)
81+
expect(isBlacklistedSingleCommand("sudo apt install", blacklistedCommands)).toBe(true)
82+
expect(isBlacklistedSingleCommand("DEL file.txt", blacklistedCommands)).toBe(true)
83+
})
84+
85+
it("matches blacklisted command prefixes", () => {
86+
expect(isBlacklistedSingleCommand("rm -rf /home", blacklistedCommands)).toBe(true)
87+
expect(isBlacklistedSingleCommand("sudo rm file", blacklistedCommands)).toBe(true)
88+
expect(isBlacklistedSingleCommand("del *.txt", blacklistedCommands)).toBe(true)
89+
})
90+
91+
it("allows non-blacklisted commands", () => {
92+
expect(isBlacklistedSingleCommand("npm test", blacklistedCommands)).toBe(false)
93+
expect(isBlacklistedSingleCommand("echo hello", blacklistedCommands)).toBe(false)
94+
expect(isBlacklistedSingleCommand("git status", blacklistedCommands)).toBe(false)
95+
})
96+
97+
it("handles empty inputs", () => {
98+
expect(isBlacklistedSingleCommand("", blacklistedCommands)).toBe(false)
99+
expect(isBlacklistedSingleCommand("rm file", [])).toBe(false)
100+
})
101+
})
102+
71103
describe("validateCommand", () => {
72104
const allowedCommands = ["npm test", "npm run", "echo", "Select-String"]
73105

@@ -121,6 +153,33 @@ describe("Command Validation", () => {
121153
expect(validateCommand("npm test $(echo dangerous)", wildcardAllowedCommands)).toBe(true)
122154
expect(validateCommand("npm test `rm -rf /`", wildcardAllowedCommands)).toBe(true)
123155
})
156+
157+
it("blocks blacklisted commands even if allowed", () => {
158+
const allowedWithBlacklisted = ["npm test", "npm run", "echo", "rm "]
159+
const blacklistedCommands = ["rm ", "sudo ", "del "]
160+
expect(validateCommand("rm -rf /", allowedWithBlacklisted, blacklistedCommands)).toBe(false)
161+
expect(validateCommand("sudo apt install", allowedWithBlacklisted, blacklistedCommands)).toBe(false)
162+
expect(validateCommand("npm test", allowedWithBlacklisted, blacklistedCommands)).toBe(true)
163+
})
164+
165+
it("validates chained commands with blacklist", () => {
166+
const blacklistedCommands = ["rm ", "sudo ", "del "]
167+
expect(validateCommand("npm test && npm run build", allowedCommands, blacklistedCommands)).toBe(true)
168+
expect(validateCommand("npm test && rm file", allowedCommands, blacklistedCommands)).toBe(false)
169+
expect(validateCommand("npm test && dangerous", allowedCommands, blacklistedCommands)).toBe(false)
170+
})
171+
172+
it("handles wildcard allowed commands with blacklist", () => {
173+
const blacklistedCommands = ["rm ", "sudo ", "del "]
174+
expect(validateCommand("any command", ["*"], blacklistedCommands)).toBe(true)
175+
expect(validateCommand("rm -rf /", ["*"], blacklistedCommands)).toBe(false) // Still blocked by blacklist
176+
expect(validateCommand("sudo apt install", ["*"], blacklistedCommands)).toBe(false) // Still blocked by blacklist
177+
})
178+
179+
it("works without blacklist parameter (backward compatibility)", () => {
180+
expect(validateCommand("npm test", allowedCommands)).toBe(true)
181+
expect(validateCommand("dangerous", allowedCommands)).toBe(false)
182+
})
124183
})
125184
})
126185

0 commit comments

Comments
 (0)