Skip to content

Commit 9b77aa7

Browse files
committed
feat: harden destructive command guard integration
1 parent bce10d6 commit 9b77aa7

32 files changed

Lines changed: 554 additions & 41 deletions

File tree

packages/types/src/__tests__/message.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import {
44
clineAsks,
5+
clineMessageSchema,
56
getCompletionCheckpoint,
67
isIdleAsk,
78
isInteractiveAsk,
@@ -21,6 +22,20 @@ describe("ask messages", () => {
2122
})
2223
})
2324

25+
describe("clineMessageSchema autoApprovalDecision", () => {
26+
it.each(["approve", "deny"] as const)("accepts %s", (autoApprovalDecision) => {
27+
expect(clineMessageSchema.safeParse({ ts: 1, type: "ask", ask: "command", autoApprovalDecision }).success).toBe(
28+
true,
29+
)
30+
})
31+
32+
it("rejects invalid decisions", () => {
33+
expect(
34+
clineMessageSchema.safeParse({ ts: 1, type: "ask", ask: "command", autoApprovalDecision: "ask" }).success,
35+
).toBe(false)
36+
})
37+
})
38+
2439
describe("getCompletionCheckpoint", () => {
2540
it("returns the first checkpoint after the latest user prompt before completion", () => {
2641
const messages: ClineMessage[] = [

packages/types/src/global-settings.ts

Lines changed: 2 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
*

src/core/auto-approval/__tests__/dcg.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ describe("Destructive Command Guard auto-approval precedence", () => {
3737
})
3838
})
3939

40+
it("does not auto-approve via DCG when execute auto-approval is off", async () => {
41+
const state = { ...baseState, alwaysAllowExecute: false }
42+
43+
expect(await checkAutoApproval({ state, ask: "command", text: "echo safe" })).toEqual({ decision: "ask" })
44+
})
45+
4046
it("keeps ordinary allowlist auto-approval when DCG is disabled", async () => {
4147
const state = { ...baseState, destructiveCommandGuardEnabled: false }
4248

src/core/tools/ExecuteCommandTool.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,13 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
135135
const providerState = await provider?.getState()
136136
let dcgBlocked = false
137137
if (providerState?.destructiveCommandGuardEnabled === true) {
138-
const { getDcgBinaryPath, runDcg } = await import("../../services/destructive-command-guard")
139-
const binaryPath = provider ? getDcgBinaryPath(provider.context.globalStorageUri.fsPath) : undefined
140-
if (!binaryPath) {
141-
throw new Error("Destructive Command Guard is enabled but is not available for this platform")
138+
const { ensureDcgInstalled, runDcg } = await import("../../services/destructive-command-guard")
139+
if (!provider) {
140+
throw new Error(t("common:errors.destructiveCommandGuard.unavailable"))
142141
}
142+
// Resolve through the managed installer on use so an extension update
143+
// automatically installs the newly pinned and verified DCG version.
144+
const binaryPath = await ensureDcgInstalled(provider.context.globalStorageUri.fsPath)
143145
const workingDirectory = customCwd
144146
? path.isAbsolute(customCwd)
145147
? customCwd

src/core/tools/__tests__/executeCommandTool.spec.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ vitest.mock("../../task/Task")
4646
vitest.mock("../../prompts/responses")
4747

4848
const mockRunDcg = vitest.fn()
49-
const mockGetDcgBinaryPath = vitest.fn()
49+
const mockEnsureDcgInstalled = vitest.fn()
5050

5151
vitest.mock("../../../services/destructive-command-guard", () => ({
5252
runDcg: mockRunDcg,
53-
getDcgBinaryPath: mockGetDcgBinaryPath,
53+
ensureDcgInstalled: mockEnsureDcgInstalled,
5454
}))
5555

5656
// Import the module
@@ -105,7 +105,7 @@ describe("executeCommandTool", () => {
105105
mockHandleError = vitest.fn().mockResolvedValue(undefined)
106106
mockPushToolResult = vitest.fn()
107107
mockRunDcg.mockResolvedValue({ decision: "allow" })
108-
mockGetDcgBinaryPath.mockReturnValue("/test/storage/dcg")
108+
mockEnsureDcgInstalled.mockResolvedValue("/test/storage/dcg")
109109

110110
// Setup vscode config mock
111111
const mockConfig = {
@@ -249,6 +249,65 @@ describe("executeCommandTool", () => {
249249
expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test", undefined, true)
250250
})
251251

252+
it("requests normal approval when DCG allows the command", async () => {
253+
const provider = await mockCline.providerRef.deref()
254+
provider.context = { globalStorageUri: { fsPath: "/test/storage" } }
255+
provider.getState.mockResolvedValue({
256+
destructiveCommandGuardEnabled: true,
257+
terminalShellIntegrationDisabled: true,
258+
})
259+
mockRunDcg.mockResolvedValue({ decision: "allow" })
260+
261+
await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
262+
askApproval: mockAskApproval as unknown as AskApproval,
263+
handleError: mockHandleError as unknown as HandleError,
264+
pushToolResult: mockPushToolResult as unknown as PushToolResult,
265+
})
266+
267+
expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test")
268+
})
269+
270+
it("installs or updates DCG before evaluating an enabled command", async () => {
271+
const provider = await mockCline.providerRef.deref()
272+
provider.context = { globalStorageUri: { fsPath: "/test/storage" } }
273+
provider.getState.mockResolvedValue({
274+
destructiveCommandGuardEnabled: true,
275+
terminalShellIntegrationDisabled: true,
276+
})
277+
await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
278+
askApproval: mockAskApproval as unknown as AskApproval,
279+
handleError: mockHandleError as unknown as HandleError,
280+
pushToolResult: mockPushToolResult as unknown as PushToolResult,
281+
})
282+
283+
expect(mockEnsureDcgInstalled).toHaveBeenCalledWith("/test/storage")
284+
expect(mockRunDcg).toHaveBeenCalledWith("/test/storage/dcg", "echo test", "/test/workspace")
285+
})
286+
287+
it("fails closed when the DCG install or update fails", async () => {
288+
const provider = await mockCline.providerRef.deref()
289+
provider.context = { globalStorageUri: { fsPath: "/test/storage" } }
290+
provider.getState.mockResolvedValue({
291+
destructiveCommandGuardEnabled: true,
292+
terminalShellIntegrationDisabled: true,
293+
})
294+
mockEnsureDcgInstalled.mockRejectedValue(new Error("download failed"))
295+
296+
await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, {
297+
askApproval: mockAskApproval as unknown as AskApproval,
298+
handleError: mockHandleError as unknown as HandleError,
299+
pushToolResult: mockPushToolResult as unknown as PushToolResult,
300+
})
301+
302+
expect(mockHandleError).toHaveBeenCalledWith(
303+
"executing command",
304+
expect.objectContaining({ message: "download failed" }),
305+
)
306+
expect(mockRunDcg).not.toHaveBeenCalled()
307+
expect(mockAskApproval).not.toHaveBeenCalled()
308+
expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled()
309+
})
310+
252311
it("should handle missing command parameter", async () => {
253312
// Setup
254313
mockToolUse.params.command = undefined

src/core/webview/ClineProvider.ts

Lines changed: 4 additions & 2 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,
@@ -2460,7 +2461,7 @@ export class ClineProvider
24602461
alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? false,
24612462
alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false,
24622463
alwaysAllowExecute: alwaysAllowExecute ?? false,
2463-
destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? false,
2464+
destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED,
24642465
alwaysAllowMcp: alwaysAllowMcp ?? false,
24652466
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
24662467
alwaysAllowSubtasks: alwaysAllowSubtasks ?? false,
@@ -2693,7 +2694,8 @@ export class ClineProvider
26932694
alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false,
26942695
alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false,
26952696
alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false,
2696-
destructiveCommandGuardEnabled: stateValues.destructiveCommandGuardEnabled ?? false,
2697+
destructiveCommandGuardEnabled:
2698+
stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED,
26972699
alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false,
26982700
alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false,
26992701
alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false,

src/i18n/locales/ca/common.json

Lines changed: 3 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: 3 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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@
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+
},
7578
"destructive_command_guard_enable_failed": "Unable to enable Destructive Command Guard: {{error}}",
7679
"share_task_failed": "Failed to share task. Please try again.",
7780
"share_no_active_task": "No active task to share",

src/i18n/locales/es/common.json

Lines changed: 3 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)