Skip to content

Commit 7d2504b

Browse files
committed
feat: integrate destructive command guard with auto-approval
Closes #1058 Completes #1049
1 parent f7ae3cb commit 7d2504b

48 files changed

Lines changed: 577 additions & 8 deletions

Some content is hidden

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

AGENTS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,22 @@ When writing new code:
1717
- After editing a file, run `pnpm --dir src exec eslint --prune-suppressions --max-warnings=0 <relative-file>` and confirm the count for that file did not increase.
1818
- If a suppression is truly unavoidable (e.g. `vi.spyOn(Cls.prototype as any, "privateMethod")` where no typed alternative exists), document why in a comment next to the cast.
1919

20+
## Persisted Setting Checklist
21+
22+
When adding or changing a user setting, trace the complete round trip. A setting is not complete merely because its control renders or its value reaches storage.
23+
24+
- [ ] Define the setting, validation, and optionality in `packages/types/src/global-settings.ts` (or the appropriate provider/settings schema). Define a shared default constant when multiple readers need the same default.
25+
- [ ] If the webview uses the setting, include it in `ExtensionState` in `packages/types/src/vscode-extension-host.ts` and any relevant message types.
26+
- [ ] In `SettingsView`, initialize and read the control from local `cachedState`, NOT directly from live `useExtensionState()`. The cache buffers edits until the user explicitly clicks Save; binding to live state causes races and discarded edits.
27+
- [ ] Update `cachedState` from the control and include the setting in the `updateSettings` payload sent by `SettingsView.handleSubmit()` (or document and test a deliberate immediate-save flow).
28+
- [ ] Verify `webviewMessageHandler` handles any setting-specific normalization or side effects and persists the final value through `ContextProxy`. Generic settings normally use `contextProxy.setValue()`.
29+
- [ ] Add the setting to `ClineProvider.getState()` with the intended default so extension/runtime consumers can read it.
30+
- [ ] Add the setting to both the destructuring and returned object in `ClineProvider.getStateToPostToWebview()`. This completes the storage-to-webview round trip and prevents a saved control from reverting visually.
31+
- [ ] Update every runtime consumer and ensure all consumers use the same default semantics.
32+
- [ ] If users can import/export the setting, verify its schema inclusion makes it round-trip and add special handling only when required (for example, secrets or non-exportable state).
33+
- [ ] Add focused tests for: UI binding/save behavior, persistence or normalization, and the saved value returned by `getStateToPostToWebview()`. Include both `true` and `false`/unset cases when defaulting can hide omissions.
34+
- [ ] Run the narrowest relevant Vitest suites from the package directory that declares Vitest.
35+
2036
## Test Placement Guidance
2137

2238
Prefer the narrowest test layer that proves the behavior. This follows standard test-pyramid guidance: keep most coverage in fast, focused tests; add integration tests for cross-module contracts; reserve end-to-end tests for full workflow confidence.

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/message.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ export const clineMessageSchema = z.object({
272272
isProtected: z.boolean().optional(),
273273
apiProtocol: z.union([z.literal("openai"), z.literal("anthropic")]).optional(),
274274
isAnswered: z.boolean().optional(),
275+
autoApprovalDecision: z.union([z.literal("approve"), z.literal("deny")]).optional(),
275276
})
276277

277278
export type ClineMessage = z.infer<typeof clineMessageSchema>
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
destructiveCommandGuardEnabled: true,
19+
mcpServers: [],
20+
}
21+
22+
it("auto-approves commands allowed by DCG without consulting Zoo's deny list", async () => {
23+
expect(await checkAutoApproval({ state: baseState, ask: "command", text: "rm file" })).toEqual({
24+
decision: "approve",
25+
})
26+
})
27+
28+
it("requires explicit approval for a DCG-protected command", async () => {
29+
expect(
30+
await checkAutoApproval({ state: baseState, ask: "command", text: "echo safe", isProtected: true }),
31+
).toEqual({ decision: "ask" })
32+
})
33+
34+
it("auto-approves DCG-allowed commands without consulting Zoo's allowlist", async () => {
35+
expect(await checkAutoApproval({ state: baseState, ask: "command", text: "unlisted-command" })).toEqual({
36+
decision: "approve",
37+
})
38+
})
39+
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+
46+
it("keeps ordinary allowlist auto-approval when DCG is disabled", async () => {
47+
const state = { ...baseState, destructiveCommandGuardEnabled: false }
48+
49+
expect(await checkAutoApproval({ state, ask: "command", text: "echo safe" })).toEqual({
50+
decision: "approve",
51+
})
52+
})
53+
54+
it("keeps ordinary denylist behavior when DCG is disabled", async () => {
55+
const state = { ...baseState, destructiveCommandGuardEnabled: false }
56+
57+
expect(await checkAutoApproval({ state, ask: "command", text: "rm file" })).toEqual({
58+
decision: "deny",
59+
})
60+
})
61+
62+
it("keeps ordinary prompts for unlisted commands when DCG is disabled", async () => {
63+
const state = { ...baseState, destructiveCommandGuardEnabled: false }
64+
65+
expect(await checkAutoApproval({ state, ask: "command", text: "unlisted-command" })).toEqual({
66+
decision: "ask",
67+
})
68+
})
69+
})

src/core/auto-approval/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type AutoApprovalStateOptions =
3333
| "mcpServers" // For `alwaysAllowMcp`.
3434
| "allowedCommands" // For `alwaysAllowExecute`.
3535
| "deniedCommands"
36+
| "destructiveCommandGuardEnabled"
3637

3738
export type CheckAutoApprovalResult =
3839
| { decision: "approve" }
@@ -115,8 +116,18 @@ export async function checkAutoApproval({
115116
if (!text) {
116117
return { decision: "ask" }
117118
}
119+
if (isProtected) {
120+
return { decision: "ask" }
121+
}
118122

119123
if (state.alwaysAllowExecute === true) {
124+
// Execute commands immediately when DCG allows them. ExecuteCommandTool
125+
// marks commands blocked by DCG as protected before reaching this check,
126+
// which keeps the explicit user approval prompt for those commands.
127+
if (state.destructiveCommandGuardEnabled === true) {
128+
return { decision: "approve" }
129+
}
130+
120131
const decision = getCommandDecision(text, state.allowedCommands || [], state.deniedCommands || [])
121132

122133
if (decision === "auto_approve") {

src/core/task/Task.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
11851185
const state = provider ? await provider.getState() : undefined
11861186
const approval = await checkAutoApproval({ state, ask: type, text, isProtected })
11871187
const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny"
1188+
const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined
11881189

11891190
if (partial !== undefined) {
11901191
const lastMessage = this.clineMessages.at(-1)
@@ -1248,6 +1249,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12481249
lastMessage.isProtected = isProtected
12491250
if (isAutoAnswered) {
12501251
lastMessage.isAnswered = true
1252+
lastMessage.autoApprovalDecision = autoApprovalDecision
12511253
}
12521254
await this.saveClineMessages()
12531255
// Fire-and-forget: see updateClineMessage call above for the
@@ -1269,6 +1271,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12691271
text,
12701272
isProtected,
12711273
isAnswered: isAutoAnswered || undefined,
1274+
autoApprovalDecision,
12721275
})
12731276
}
12741277
}
@@ -1286,6 +1289,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
12861289
text,
12871290
isProtected,
12881291
isAnswered: isAutoAnswered || undefined,
1292+
autoApprovalDecision,
12891293
})
12901294
}
12911295

src/core/tools/ExecuteCommandTool.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ interface ExecuteCommandParams {
6060
timeout?: number | null
6161
}
6262

63+
export function formatDcgBlockedMessage(reason?: string, ruleId?: string): string {
64+
if (reason && ruleId) {
65+
return t("tools:executeCommand.destructiveCommandGuard.blockedWithReasonAndRule", { reason, ruleId })
66+
}
67+
68+
if (reason) {
69+
return t("tools:executeCommand.destructiveCommandGuard.blockedWithReason", { reason })
70+
}
71+
72+
if (ruleId) {
73+
return t("tools:executeCommand.destructiveCommandGuard.blockedWithRule", { ruleId })
74+
}
75+
76+
return t("tools:executeCommand.destructiveCommandGuard.blocked")
77+
}
78+
6379
export function resolveAgentTimeoutMs(timeoutSeconds: number | null | undefined): number {
6480
const requestedAgentTimeout = typeof timeoutSeconds === "number" && timeoutSeconds > 0 ? timeoutSeconds * 1000 : 0
6581

@@ -115,16 +131,44 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
115131
return
116132
}
117133

118-
const didApprove = await askApproval("command", canonicalCommand)
134+
const provider = await task.providerRef.deref()
135+
const providerState = await provider?.getState()
136+
let dcgBlocked = false
137+
if (providerState?.destructiveCommandGuardEnabled === true) {
138+
const { ensureDcgInstalled, runDcg } = await import("../../services/destructive-command-guard")
139+
if (!provider) {
140+
throw new Error(t("common:errors.destructiveCommandGuard.unavailable"))
141+
}
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)
145+
if (!binaryPath) {
146+
throw new Error(t("common:errors.destructiveCommandGuard.unavailable"))
147+
}
148+
const workingDirectory = customCwd
149+
? path.isAbsolute(customCwd)
150+
? customCwd
151+
: path.resolve(task.cwd, customCwd)
152+
: task.cwd
153+
const dcgResult = await runDcg(binaryPath, canonicalCommand, workingDirectory)
154+
dcgBlocked = dcgResult.decision === "deny"
155+
if (dcgResult.decision === "deny") {
156+
await task.say("error", formatDcgBlockedMessage(dcgResult.reason, dcgResult.ruleId))
157+
}
158+
}
159+
160+
// DCG-approved commands are auto-approved by checkAutoApproval. A DCG
161+
// block is presented as Zoo's normal command prompt, with isProtected
162+
// forcing the user to explicitly choose whether to execute it.
163+
const didApprove = dcgBlocked
164+
? await askApproval("command", canonicalCommand, undefined, true)
165+
: await askApproval("command", canonicalCommand)
119166

120167
if (!didApprove) {
121168
return
122169
}
123170

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

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

0 commit comments

Comments
 (0)