Skip to content

Commit 6c35c4e

Browse files
committed
implement dcg
1 parent 1c9db21 commit 6c35c4e

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
@@ -115,16 +115,42 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
115115
return
116116
}
117117

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

120149
if (!didApprove) {
121150
return
122151
}
123152

124153
const executionId = task.lastMessageTs?.toString() ?? Date.now().toString()
125-
const provider = await task.providerRef.deref()
126-
const providerState = await provider?.getState()
127-
128154
const { terminalShellIntegrationDisabled = true } = providerState ?? {}
129155

130156
// 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
@@ -2311,6 +2311,7 @@ export class ClineProvider
23112311
alwaysAllowWriteProtected,
23122312
alwaysAllowExecute,
23132313
alwaysAllowCommandsExceptDenied,
2314+
destructiveCommandGuardEnabled,
23142315
allowedCommands,
23152316
deniedCommands,
23162317
alwaysAllowMcp,
@@ -2461,6 +2462,7 @@ export class ClineProvider
24612462
alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false,
24622463
alwaysAllowExecute: alwaysAllowExecute ?? false,
24632464
alwaysAllowCommandsExceptDenied: alwaysAllowCommandsExceptDenied ?? false,
2465+
destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? false,
24642466
alwaysAllowMcp: alwaysAllowMcp ?? false,
24652467
alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false,
24662468
alwaysAllowSubtasks: alwaysAllowSubtasks ?? false,
@@ -2694,6 +2696,7 @@ export class ClineProvider
26942696
alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false,
26952697
alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false,
26962698
alwaysAllowCommandsExceptDenied: stateValues.alwaysAllowCommandsExceptDenied ?? false,
2699+
destructiveCommandGuardEnabled: stateValues.destructiveCommandGuardEnabled ?? false,
26972700
alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false,
26982701
alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false,
26992702
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
@@ -1020,6 +1020,15 @@ describe("ClineProvider", () => {
10201020
expect(state.alwaysAllowCommandsExceptDenied).toBe(true)
10211021
})
10221022

1023+
test("getStateToPostToWebview returns the saved destructive command guard setting", async () => {
1024+
await provider.resolveWebviewView(mockWebviewView)
1025+
await provider.contextProxy.setValue("destructiveCommandGuardEnabled", true)
1026+
1027+
const state = await provider.getStateToPostToWebview()
1028+
1029+
expect(state.destructiveCommandGuardEnabled).toBe(true)
1030+
})
1031+
10231032
test("language is set to VSCode language", async () => {
10241033
// Mock VSCode language as Spanish
10251034
;(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
@@ -681,6 +681,18 @@ 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+
await ensureDcgInstalled(provider.context.globalStorageUri.fsPath)
688+
} catch (error) {
689+
message.updatedSettings.destructiveCommandGuardEnabled = false
690+
vscode.window.showErrorMessage(
691+
`Unable to enable Destructive Command Guard: ${error instanceof Error ? error.message : String(error)}`,
692+
)
693+
}
694+
}
695+
684696
for (const [key, value] of Object.entries(message.updatedSettings)) {
685697
let newValue = value
686698

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)