Skip to content

Commit 59882c2

Browse files
committed
implement dcg
1 parent fe92db3 commit 59882c2

34 files changed

Lines changed: 651 additions & 53 deletions

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export const globalSettingsSchema = z.object({
137137
alwaysAllowSubtasks: z.boolean().optional(),
138138
alwaysAllowExecute: z.boolean().optional(),
139139
alwaysAllowCommandsExceptDenied: z.boolean().optional(),
140+
destructiveCommandGuardEnabled: z.boolean().optional(),
140141
alwaysAllowFollowupQuestions: z.boolean().optional(),
141142
followupAutoApproveTimeoutMs: z.number().optional(),
142143
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
@@ -275,6 +275,7 @@ export type ExtensionState = Pick<
275275
| "alwaysAllowFollowupQuestions"
276276
| "alwaysAllowExecute"
277277
| "alwaysAllowCommandsExceptDenied"
278+
| "destructiveCommandGuardEnabled"
278279
| "followupAutoApproveTimeoutMs"
279280
| "allowedCommands"
280281
| "deniedCommands"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { checkAutoApproval } from ".."
2+
3+
describe("Destructive Command Guard auto-approval precedence", () => {
4+
const baseState = {
5+
autoApprovalEnabled: true,
6+
alwaysAllowExecute: true,
7+
alwaysAllowReadOnly: false,
8+
alwaysAllowReadOnlyOutsideWorkspace: false,
9+
alwaysAllowWrite: false,
10+
alwaysAllowWriteOutsideWorkspace: false,
11+
alwaysAllowWriteProtected: false,
12+
alwaysAllowMcp: false,
13+
alwaysAllowModeSwitch: false,
14+
alwaysAllowSubtasks: false,
15+
alwaysAllowFollowupQuestions: false,
16+
allowedCommands: ["echo"],
17+
deniedCommands: ["rm"],
18+
alwaysAllowCommandsExceptDenied: false,
19+
destructiveCommandGuardEnabled: true,
20+
mcpServers: [],
21+
}
22+
23+
it("ignores Zoo's deny list while DCG is enabled", async () => {
24+
expect(await checkAutoApproval({ state: baseState, ask: "command", text: "rm file" })).toEqual({
25+
decision: "ask",
26+
})
27+
})
28+
29+
it("requires explicit approval for a DCG-protected command", async () => {
30+
expect(
31+
await checkAutoApproval({ state: baseState, ask: "command", text: "echo safe", isProtected: true }),
32+
).toEqual({ decision: "ask" })
33+
})
34+
35+
it("retains ordinary allowlist auto-approval for DCG-allowed commands", async () => {
36+
expect(await checkAutoApproval({ state: baseState, ask: "command", text: "echo safe" })).toEqual({
37+
decision: "approve",
38+
})
39+
})
40+
})

src/core/auto-approval/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type AutoApprovalStateOptions =
3434
| "allowedCommands" // For `alwaysAllowExecute`.
3535
| "deniedCommands"
3636
| "alwaysAllowCommandsExceptDenied"
37+
| "destructiveCommandGuardEnabled"
3738

3839
export type CheckAutoApprovalResult =
3940
| { decision: "approve" }
@@ -116,12 +117,17 @@ export async function checkAutoApproval({
116117
if (!text) {
117118
return { decision: "ask" }
118119
}
120+
if (isProtected) {
121+
return { decision: "ask" }
122+
}
119123

124+
// DCG only changes commands that its policy blocks. Commands that pass
125+
// continue through Zoo's existing allowlist/permission flow.
120126
if (state.alwaysAllowExecute === true) {
121127
const decision = getCommandDecision(
122128
text,
123129
state.allowedCommands || [],
124-
state.deniedCommands || [],
130+
state.destructiveCommandGuardEnabled === true ? [] : state.deniedCommands || [],
125131
state.alwaysAllowCommandsExceptDenied === true,
126132
)
127133

src/core/tools/ExecuteCommandTool.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,42 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
106106
return
107107
}
108108

109-
const didApprove = await askApproval("command", canonicalCommand)
109+
const provider = await task.providerRef.deref()
110+
const providerState = await provider?.getState()
111+
let dcgBlocked = false
112+
if (providerState?.destructiveCommandGuardEnabled === true) {
113+
const { getDcgBinaryPath, runDcg } = await import("../../services/destructive-command-guard")
114+
const binaryPath = provider ? getDcgBinaryPath(provider.context.globalStorageUri.fsPath) : undefined
115+
if (!binaryPath) {
116+
throw new Error("Destructive Command Guard is enabled but is not available for this platform")
117+
}
118+
const workingDirectory = customCwd
119+
? path.isAbsolute(customCwd)
120+
? customCwd
121+
: path.resolve(task.cwd, customCwd)
122+
: task.cwd
123+
const dcgResult = await runDcg(binaryPath, canonicalCommand, workingDirectory)
124+
dcgBlocked = dcgResult.decision === "deny"
125+
if (dcgResult.decision === "deny") {
126+
await task.say(
127+
"text",
128+
`Destructive Command Guard blocked this command${dcgResult.reason ? `: ${dcgResult.reason}` : "."}${dcgResult.ruleId ? ` (Rule: ${dcgResult.ruleId})` : ""}`,
129+
)
130+
}
131+
}
132+
133+
// A DCG block is intentionally presented as Zoo's normal command approval
134+
// prompt. Passing isProtected bypasses command auto-approval so the user
135+
// must explicitly choose whether to execute it.
136+
const didApprove = dcgBlocked
137+
? await askApproval("command", canonicalCommand, undefined, true)
138+
: await askApproval("command", canonicalCommand)
110139

111140
if (!didApprove) {
112141
return
113142
}
114143

115144
const executionId = task.lastMessageTs?.toString() ?? Date.now().toString()
116-
const provider = await task.providerRef.deref()
117-
const providerState = await provider?.getState()
118-
119145
const { terminalShellIntegrationDisabled = true } = providerState ?? {}
120146

121147
// Get command execution timeout from VSCode configuration (in seconds)

src/core/webview/ClineProvider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2291,6 +2291,7 @@ export class ClineProvider
22912291
alwaysAllowWriteProtected,
22922292
alwaysAllowExecute,
22932293
alwaysAllowCommandsExceptDenied,
2294+
destructiveCommandGuardEnabled,
22942295
allowedCommands,
22952296
deniedCommands,
22962297
alwaysAllowMcp,
@@ -2441,6 +2442,7 @@ export class ClineProvider
24412442
alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false,
24422443
alwaysAllowExecute: alwaysAllowExecute ?? false,
24432444
alwaysAllowCommandsExceptDenied: alwaysAllowCommandsExceptDenied ?? false,
2445+
destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? false,
24442446
alwaysAllowMcp: alwaysAllowMcp ?? false,
24452447
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
24462448
alwaysAllowSubtasks: alwaysAllowSubtasks ?? false,
@@ -2674,6 +2676,7 @@ export class ClineProvider
26742676
alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false,
26752677
alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false,
26762678
alwaysAllowCommandsExceptDenied: stateValues.alwaysAllowCommandsExceptDenied ?? false,
2679+
destructiveCommandGuardEnabled: stateValues.destructiveCommandGuardEnabled ?? false,
26772680
alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false,
26782681
alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false,
26792682
alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false,

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,15 @@ describe("ClineProvider", () => {
958958
expect(state.alwaysAllowCommandsExceptDenied).toBe(true)
959959
})
960960

961+
test("getStateToPostToWebview returns the saved destructive command guard setting", async () => {
962+
await provider.resolveWebviewView(mockWebviewView)
963+
await provider.contextProxy.setValue("destructiveCommandGuardEnabled", true)
964+
965+
const state = await provider.getStateToPostToWebview()
966+
967+
expect(state.destructiveCommandGuardEnabled).toBe(true)
968+
})
969+
961970
test("language is set to VSCode language", async () => {
962971
// Mock VSCode language as Spanish
963972
;(vscode.env as any).language = "pt-BR"

src/core/webview/webviewMessageHandler.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,18 @@ export const webviewMessageHandler = async (
677677

678678
case "updateSettings":
679679
if (message.updatedSettings) {
680+
if (message.updatedSettings.destructiveCommandGuardEnabled === true) {
681+
try {
682+
const { ensureDcgInstalled } = await import("../../services/destructive-command-guard")
683+
await ensureDcgInstalled(provider.context.globalStorageUri.fsPath)
684+
} catch (error) {
685+
message.updatedSettings.destructiveCommandGuardEnabled = false
686+
vscode.window.showErrorMessage(
687+
`Unable to enable Destructive Command Guard: ${error instanceof Error ? error.message : String(error)}`,
688+
)
689+
}
690+
}
691+
680692
for (const [key, value] of Object.entries(message.updatedSettings)) {
681693
let newValue = value
682694

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { DCG_ARCHIVES } from "../constants"
2+
import { getDcgArchiveInfo, getDcgBinaryPath, isDcgSupportedPlatform } from "../manager"
3+
4+
describe("Destructive Command Guard manager", () => {
5+
it("maps all supported platform and architecture combinations", () => {
6+
expect(Object.keys(DCG_ARCHIVES).sort()).toEqual([
7+
"darwin-arm64",
8+
"darwin-x64",
9+
"linux-arm64",
10+
"linux-x64",
11+
"win32-arm64",
12+
"win32-x64",
13+
])
14+
expect(getDcgArchiveInfo("darwin", "arm64")?.archive).toBe("dcg-aarch64-apple-darwin.tar.xz")
15+
expect(getDcgArchiveInfo("win32", "x64")?.binary).toBe("dcg.exe")
16+
})
17+
18+
it("rejects unsupported platforms", () => {
19+
expect(isDcgSupportedPlatform("freebsd", "x64")).toBe(false)
20+
expect(getDcgBinaryPath("/storage", "freebsd", "x64")).toBeUndefined()
21+
})
22+
23+
it("returns a versioned managed binary path", () => {
24+
expect(getDcgBinaryPath("/storage", "linux", "x64")).toMatch(
25+
/[/\\]destructive-command-guard[/\\]v0\.7\.7[/\\]dcg$/,
26+
)
27+
})
28+
})
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export const DCG_VERSION = "v0.7.7"
2+
3+
export type DcgArchiveInfo = {
4+
archive: string
5+
binary: "dcg" | "dcg.exe"
6+
sha256: string
7+
}
8+
9+
export const DCG_ARCHIVES: Readonly<Record<string, DcgArchiveInfo>> = {
10+
"darwin-arm64": {
11+
archive: "dcg-aarch64-apple-darwin.tar.xz",
12+
binary: "dcg",
13+
sha256: "a63cf82bd3584055112d5ec7a4ab3d7e0619a9f806a53930c27aa0e6297484de",
14+
},
15+
"darwin-x64": {
16+
archive: "dcg-x86_64-apple-darwin.tar.xz",
17+
binary: "dcg",
18+
sha256: "15b42fbbbeab47123899e6328d90cd593e14999f3d275f71294815ad8ed9479c",
19+
},
20+
"linux-arm64": {
21+
archive: "dcg-aarch64-unknown-linux-gnu.tar.xz",
22+
binary: "dcg",
23+
sha256: "abb0d94f23ab50f9edc16f8ca6939ff8eec23e1831d3ad7a28d9f03252c3306d",
24+
},
25+
"linux-x64": {
26+
archive: "dcg-x86_64-unknown-linux-musl.tar.xz",
27+
binary: "dcg",
28+
sha256: "472b130a9b235edc57e6cb7566641da5fef905e9dbefd3a46f9ad1e33205fa04",
29+
},
30+
"win32-arm64": {
31+
archive: "dcg-aarch64-pc-windows-msvc.zip",
32+
binary: "dcg.exe",
33+
sha256: "93d4c71860086db00bea3aa957051cef38eac572422fa40b2a045c8cd578c3b5",
34+
},
35+
"win32-x64": {
36+
archive: "dcg-x86_64-pc-windows-msvc.zip",
37+
binary: "dcg.exe",
38+
sha256: "435127410eabc53e772be4f5c668a875b45fbaf806654b577c2d975bd0e38964",
39+
},
40+
}
41+
42+
export const DCG_DOWNLOAD_BASE_URL = `https://github.com/Dicklesworthstone/destructive_command_guard/releases/download/${DCG_VERSION}`
43+
44+
export const DCG_MAX_ARCHIVE_BYTES = 16 * 1024 * 1024
45+
export const DCG_DOWNLOAD_TIMEOUT_MS = 60_000
46+
export const DCG_RUN_TIMEOUT_MS = 3_000
47+
export const DCG_MAX_OUTPUT_BYTES = 256 * 1024
48+
49+
export const DCG_TRUSTED_DOWNLOAD_DOMAINS = [
50+
"github.com",
51+
"objects.githubusercontent.com",
52+
"release-assets.githubusercontent.com",
53+
] as const

0 commit comments

Comments
 (0)