Skip to content

Commit e655ee2

Browse files
committed
feat: add persisted destructive command guard setting
Refs #1057
1 parent 5c460bd commit e655ee2

45 files changed

Lines changed: 445 additions & 91 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/types/src/global-settings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ export const DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES = false
4646
*/
4747
export const DEFAULT_DIFF_FUZZY_THRESHOLD = 1.0
4848

49+
export const DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED = false
50+
4951
/**
5052
* Terminal output preview size options for persisted command output.
5153
*
@@ -136,6 +138,7 @@ export const globalSettingsSchema = z.object({
136138
alwaysAllowModeSwitch: z.boolean().optional(),
137139
alwaysAllowSubtasks: z.boolean().optional(),
138140
alwaysAllowExecute: z.boolean().optional(),
141+
destructiveCommandGuardEnabled: z.boolean().optional(),
139142
alwaysAllowFollowupQuestions: z.boolean().optional(),
140143
followupAutoApproveTimeoutMs: z.number().optional(),
141144
allowedCommands: z.array(z.string()).optional(),

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ export type ExtensionState = Pick<
274274
| "alwaysAllowSubtasks"
275275
| "alwaysAllowFollowupQuestions"
276276
| "alwaysAllowExecute"
277+
| "destructiveCommandGuardEnabled"
277278
| "followupAutoApproveTimeoutMs"
278279
| "allowedCommands"
279280
| "deniedCommands"

src/core/webview/ClineProvider.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
openRouterDefaultModelId,
4343
DEFAULT_WRITE_DELAY_MS,
4444
DEFAULT_DIFF_FUZZY_THRESHOLD,
45+
DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED,
4546
DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES,
4647
DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED,
4748
DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES,
@@ -2310,6 +2311,7 @@ export class ClineProvider
23102311
alwaysAllowWriteOutsideWorkspace,
23112312
alwaysAllowWriteProtected,
23122313
alwaysAllowExecute,
2314+
destructiveCommandGuardEnabled,
23132315
allowedCommands,
23142316
deniedCommands,
23152317
alwaysAllowMcp,
@@ -2459,6 +2461,7 @@ export class ClineProvider
24592461
alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? false,
24602462
alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false,
24612463
alwaysAllowExecute: alwaysAllowExecute ?? false,
2464+
destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED,
24622465
alwaysAllowMcp: alwaysAllowMcp ?? false,
24632466
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
24642467
alwaysAllowSubtasks: alwaysAllowSubtasks ?? false,
@@ -2691,6 +2694,8 @@ export class ClineProvider
26912694
alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false,
26922695
alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false,
26932696
alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false,
2697+
destructiveCommandGuardEnabled:
2698+
stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED,
26942699
alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false,
26952700
alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false,
26962701
alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false,

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,23 @@ describe("ClineProvider", () => {
10111011
expect(state).toHaveProperty("writeDelayMs")
10121012
})
10131013

1014+
test("getStateToPostToWebview returns the saved destructive command guard setting", async () => {
1015+
await provider.resolveWebviewView(mockWebviewView)
1016+
await provider.contextProxy.setValue("destructiveCommandGuardEnabled", true)
1017+
1018+
const state = await provider.getStateToPostToWebview()
1019+
1020+
expect(state.destructiveCommandGuardEnabled).toBe(true)
1021+
})
1022+
1023+
test("getStateToPostToWebview disables destructive command guard by default", async () => {
1024+
await provider.resolveWebviewView(mockWebviewView)
1025+
1026+
const state = await provider.getStateToPostToWebview()
1027+
1028+
expect(state.destructiveCommandGuardEnabled).toBe(false)
1029+
})
1030+
10141031
test("language is set to VSCode language", async () => {
10151032
// Mock VSCode language as Spanish
10161033
;(vscode.env as any).language = "pt-BR"

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ vi.mock("../../../services/command/commands", () => ({
2626
getCommands: vi.fn(),
2727
}))
2828

29+
vi.mock("../../../services/destructive-command-guard", () => ({
30+
ensureDcgInstalled: vi.fn(),
31+
}))
32+
2933
vi.mock("@anthropic-ai/vertex-sdk", () => ({
3034
AnthropicVertex: vi.fn(),
3135
}))
@@ -58,6 +62,7 @@ import type { ClineProvider } from "../ClineProvider"
5862
import { flushModels, getModels } from "../../../api/providers/fetchers/modelCache"
5963
import { getLMStudioModels } from "../../../api/providers/fetchers/lmstudio"
6064
import { getCommands } from "../../../services/command/commands"
65+
import { ensureDcgInstalled } from "../../../services/destructive-command-guard"
6166
import {
6267
handleCreateRule,
6368
handleDeleteRule,
@@ -1098,6 +1103,79 @@ describe("webviewMessageHandler - mcpEnabled", () => {
10981103
})
10991104
})
11001105

1106+
describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => {
1107+
beforeEach(() => {
1108+
vi.clearAllMocks()
1109+
vi.mocked(ensureDcgInstalled).mockResolvedValue("/mock/global/storage/dcg")
1110+
})
1111+
1112+
it("installs and persists destructive command guard when enabled", async () => {
1113+
await webviewMessageHandler(mockClineProvider, {
1114+
type: "updateSettings",
1115+
updatedSettings: { destructiveCommandGuardEnabled: true },
1116+
})
1117+
1118+
expect(ensureDcgInstalled).toHaveBeenCalledWith("/mock/global/storage")
1119+
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", true)
1120+
expect(vscode.window.showErrorMessage).not.toHaveBeenCalled()
1121+
})
1122+
1123+
it("disables the setting and reports an installation failure", async () => {
1124+
vi.mocked(ensureDcgInstalled).mockRejectedValue(new Error("checksum mismatch"))
1125+
1126+
await webviewMessageHandler(mockClineProvider, {
1127+
type: "updateSettings",
1128+
updatedSettings: { destructiveCommandGuardEnabled: true },
1129+
})
1130+
1131+
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false)
1132+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
1133+
"common:errors.destructive_command_guard_enable_failed",
1134+
)
1135+
})
1136+
1137+
it("disables the setting when DCG is unavailable for the current platform", async () => {
1138+
vi.mocked(ensureDcgInstalled).mockResolvedValue(undefined)
1139+
1140+
await webviewMessageHandler(mockClineProvider, {
1141+
type: "updateSettings",
1142+
updatedSettings: { destructiveCommandGuardEnabled: true },
1143+
})
1144+
1145+
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false)
1146+
expect(t).toHaveBeenCalledWith("common:errors.destructive_command_guard_enable_failed", {
1147+
error: "common:errors.destructiveCommandGuard.unavailable",
1148+
})
1149+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
1150+
"common:errors.destructive_command_guard_enable_failed",
1151+
)
1152+
})
1153+
1154+
it("reports non-Error installation failures", async () => {
1155+
vi.mocked(ensureDcgInstalled).mockRejectedValue("download unavailable")
1156+
1157+
await webviewMessageHandler(mockClineProvider, {
1158+
type: "updateSettings",
1159+
updatedSettings: { destructiveCommandGuardEnabled: true },
1160+
})
1161+
1162+
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false)
1163+
expect(t).toHaveBeenCalledWith("common:errors.destructive_command_guard_enable_failed", {
1164+
error: "download unavailable",
1165+
})
1166+
})
1167+
1168+
it("persists disabled state without trying to install", async () => {
1169+
await webviewMessageHandler(mockClineProvider, {
1170+
type: "updateSettings",
1171+
updatedSettings: { destructiveCommandGuardEnabled: false },
1172+
})
1173+
1174+
expect(ensureDcgInstalled).not.toHaveBeenCalled()
1175+
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false)
1176+
})
1177+
})
1178+
11011179
describe("webviewMessageHandler - terminalProfile", () => {
11021180
beforeEach(() => {
11031181
vi.clearAllMocks()

src/core/webview/webviewMessageHandler.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,23 @@ export const webviewMessageHandler = async (
681681

682682
case "updateSettings":
683683
if (message.updatedSettings) {
684+
if (message.updatedSettings.destructiveCommandGuardEnabled === true) {
685+
try {
686+
const { ensureDcgInstalled } = await import("../../services/destructive-command-guard")
687+
const binaryPath = await ensureDcgInstalled(provider.context.globalStorageUri.fsPath)
688+
if (!binaryPath) {
689+
throw new Error(t("common:errors.destructiveCommandGuard.unavailable"))
690+
}
691+
} catch (error) {
692+
message.updatedSettings.destructiveCommandGuardEnabled = false
693+
vscode.window.showErrorMessage(
694+
t("common:errors.destructive_command_guard_enable_failed", {
695+
error: error instanceof Error ? error.message : String(error),
696+
}),
697+
)
698+
}
699+
}
700+
684701
for (const [key, value] of Object.entries(message.updatedSettings)) {
685702
let newValue = value
686703

src/i18n/locales/ca/common.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/i18n/locales/de/common.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/i18n/locales/en/common.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@
7272
"url_fetch_failed": "Failed to fetch URL content: {{error}}",
7373
"url_fetch_error_with_url": "Error fetching content for {{url}}: {{error}}",
7474
"command_timeout": "Command execution timed out after {{seconds}} seconds",
75+
"destructiveCommandGuard": {
76+
"unavailable": "Destructive Command Guard is enabled but is not available for this platform"
77+
},
78+
"destructive_command_guard_enable_failed": "Unable to enable Destructive Command Guard: {{error}}",
7579
"share_task_failed": "Failed to share task. Please try again.",
7680
"share_no_active_task": "No active task to share",
7781
"share_auth_required": "Authentication required. Please sign in to share tasks.",

src/i18n/locales/es/common.json

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)