Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e93b1cc
refactor: extract managed binary installation infrastructure
navedmerchant Jul 30, 2026
24d527e
fix: address managed binary review feedback
navedmerchant Aug 1, 2026
3460748
feat: add destructive command guard binary service
navedmerchant Jul 30, 2026
e1a0c39
test: strengthen DCG binary service coverage
navedmerchant Aug 1, 2026
eaff17a
feat: add persisted destructive command guard setting
navedmerchant Jul 30, 2026
f7ae3cb
fix: clarify DCG enablement errors
navedmerchant Aug 1, 2026
76a1dae
test: cover DCG global setting schema
navedmerchant Aug 1, 2026
fd8ec3a
feat: integrate destructive command guard with auto-approval
navedmerchant Jul 30, 2026
e996b73
test: document DCG auto-approval precedence
navedmerchant Aug 1, 2026
ca0f252
test: raise DCG setting patch coverage
navedmerchant Aug 1, 2026
bae4ff7
fix: address DCG service review feedback
navedmerchant Aug 2, 2026
28d81f9
fix: address managed binary review feedback
navedmerchant Aug 2, 2026
09314e3
test: cover managed binary cleanup boundaries
navedmerchant Aug 2, 2026
899edc0
Merge branch 'feat/managed-binary-infrastructure' into feat/dcg-binar…
navedmerchant Aug 2, 2026
5f3854a
fix: address DCG binary service feedback
navedmerchant Aug 2, 2026
2944348
Merge branch 'feat/dcg-binary-service' into feat/dcg-setting
navedmerchant Aug 2, 2026
d72130d
Merge branch 'feat/dcg-setting' into feat/dcg-command-integration
navedmerchant Aug 2, 2026
900282f
fix: address DCG integration feedback
navedmerchant Aug 2, 2026
1f1af77
fix: finalize managed binary download handling
navedmerchant Aug 2, 2026
ef79ee2
Merge branch 'feat/managed-binary-infrastructure' into feat/dcg-binar…
navedmerchant Aug 2, 2026
982194e
Merge branch 'feat/dcg-binary-service' into feat/dcg-setting
navedmerchant Aug 2, 2026
b6b1e21
Merge branch 'feat/dcg-setting' into feat/dcg-command-integration
navedmerchant Aug 2, 2026
5074ab6
test: mirror download stream close events
navedmerchant Aug 2, 2026
ae26434
test: mirror download stream close events
navedmerchant Aug 2, 2026
465557b
Merge branch 'feat/managed-binary-infrastructure' into feat/dcg-binar…
navedmerchant Aug 2, 2026
ee3a29d
Merge branch 'feat/dcg-binary-service' into feat/dcg-setting
navedmerchant Aug 2, 2026
84c3e66
Merge branch 'feat/dcg-setting' into feat/dcg-command-integration
navedmerchant Aug 2, 2026
10476b3
Merge branch 'main' into feat/dcg-command-integration
edelauna Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ When writing new code:
- 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.
- 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.

## Persisted Setting Checklist

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.

- [ ] 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.
- [ ] If the webview uses the setting, include it in `ExtensionState` in `packages/types/src/vscode-extension-host.ts` and any relevant message types.
- [ ] 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.
- [ ] 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).
- [ ] Verify `webviewMessageHandler` handles any setting-specific normalization or side effects and persists the final value through `ContextProxy`. Generic settings normally use `contextProxy.setValue()`.
- [ ] Add the setting to `ClineProvider.getState()` with the intended default so extension/runtime consumers can read it.
- [ ] 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.
- [ ] Update every runtime consumer and ensure all consumers use the same default semantics.
- [ ] 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).
- [ ] 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.
- [ ] Run the narrowest relevant Vitest suites from the package directory that declares Vitest.

## Test Placement Guidance

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.
Expand Down
15 changes: 15 additions & 0 deletions packages/types/src/__tests__/message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import {
clineAsks,
clineMessageSchema,
getCompletionCheckpoint,
isIdleAsk,
isInteractiveAsk,
Expand All @@ -21,6 +22,20 @@ describe("ask messages", () => {
})
})

describe("clineMessageSchema autoApprovalDecision", () => {
it.each(["approve", "deny"] as const)("accepts %s", (autoApprovalDecision) => {
expect(clineMessageSchema.safeParse({ ts: 1, type: "ask", ask: "command", autoApprovalDecision }).success).toBe(
true,
)
})

it("rejects invalid decisions", () => {
expect(
clineMessageSchema.safeParse({ ts: 1, type: "ask", ask: "command", autoApprovalDecision: "ask" }).success,
).toBe(false)
})
})

describe("getCompletionCheckpoint", () => {
it("returns the first checkpoint after the latest user prompt before completion", () => {
const messages: ClineMessage[] = [
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export const clineMessageSchema = z.object({
isProtected: z.boolean().optional(),
apiProtocol: z.union([z.literal("openai"), z.literal("anthropic")]).optional(),
isAnswered: z.boolean().optional(),
autoApprovalDecision: z.union([z.literal("approve"), z.literal("deny")]).optional(),
})

export type ClineMessage = z.infer<typeof clineMessageSchema>
Expand Down
75 changes: 75 additions & 0 deletions src/core/auto-approval/__tests__/dcg.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { checkAutoApproval } from ".."

describe("Destructive Command Guard auto-approval precedence", () => {
const baseState = {
autoApprovalEnabled: true,
Comment thread
navedmerchant marked this conversation as resolved.
alwaysAllowExecute: true,
alwaysAllowReadOnly: false,
alwaysAllowReadOnlyOutsideWorkspace: false,
alwaysAllowWrite: false,
alwaysAllowWriteOutsideWorkspace: false,
alwaysAllowWriteProtected: false,
alwaysAllowMcp: false,
alwaysAllowModeSwitch: false,
alwaysAllowSubtasks: false,
alwaysAllowFollowupQuestions: false,
allowedCommands: ["echo"],
deniedCommands: ["rm"],
destructiveCommandGuardEnabled: true,
mcpServers: [],
}

it("auto-approves commands allowed by DCG without consulting Zoo's deny list", async () => {
expect(await checkAutoApproval({ state: baseState, ask: "command", text: "rm file" })).toEqual({
decision: "approve",
})
})

it("does not auto-approve through DCG when global auto-approval is disabled", async () => {
const state = { ...baseState, autoApprovalEnabled: false }

expect(await checkAutoApproval({ state, ask: "command", text: "rm file" })).toEqual({ decision: "ask" })
})

it("requires explicit approval for a DCG-protected command", async () => {
expect(
await checkAutoApproval({ state: baseState, ask: "command", text: "echo safe", isProtected: true }),
).toEqual({ decision: "ask" })
})

it("auto-approves DCG-allowed commands without consulting Zoo's allowlist", async () => {
expect(await checkAutoApproval({ state: baseState, ask: "command", text: "unlisted-command" })).toEqual({
decision: "approve",
})
})

it("does not auto-approve via DCG when execute auto-approval is off", async () => {
const state = { ...baseState, alwaysAllowExecute: false }

expect(await checkAutoApproval({ state, ask: "command", text: "echo safe" })).toEqual({ decision: "ask" })
})

it("keeps ordinary allowlist auto-approval when DCG is disabled", async () => {
const state = { ...baseState, destructiveCommandGuardEnabled: false }

expect(await checkAutoApproval({ state, ask: "command", text: "echo safe" })).toEqual({
decision: "approve",
})
})

it("keeps ordinary denylist behavior when DCG is disabled", async () => {
const state = { ...baseState, destructiveCommandGuardEnabled: false }

expect(await checkAutoApproval({ state, ask: "command", text: "rm file" })).toEqual({
decision: "deny",
})
})

it("keeps ordinary prompts for unlisted commands when DCG is disabled", async () => {
const state = { ...baseState, destructiveCommandGuardEnabled: false }

expect(await checkAutoApproval({ state, ask: "command", text: "unlisted-command" })).toEqual({
decision: "ask",
})
})
})
13 changes: 13 additions & 0 deletions src/core/auto-approval/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type AutoApprovalStateOptions =
| "mcpServers" // For `alwaysAllowMcp`.
| "allowedCommands" // For `alwaysAllowExecute`.
| "deniedCommands"
| "destructiveCommandGuardEnabled"

export type CheckAutoApprovalResult =
| { decision: "approve" }
Expand Down Expand Up @@ -115,8 +116,20 @@ export async function checkAutoApproval({
if (!text) {
return { decision: "ask" }
}
if (isProtected) {
return { decision: "ask" }
}

if (state.alwaysAllowExecute === true) {
// Execute commands immediately when DCG allows them. ExecuteCommandTool
// marks commands blocked by DCG as protected before reaching this check,
// which keeps the explicit user approval prompt for those commands. When
// enabled, DCG is the authoritative command policy, so Zoo's allow and deny
// lists are intentionally bypassed for commands that DCG allows.
if (state.destructiveCommandGuardEnabled === true) {
return { decision: "approve" }
Comment thread
navedmerchant marked this conversation as resolved.
}

const decision = getCommandDecision(text, state.allowedCommands || [], state.deniedCommands || [])

if (decision === "auto_approve") {
Expand Down
4 changes: 4 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const state = provider ? await provider.getState() : undefined
const approval = await checkAutoApproval({ state, ask: type, text, isProtected })
const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny"
const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined

if (partial !== undefined) {
const lastMessage = this.clineMessages.at(-1)
Expand Down Expand Up @@ -1271,6 +1272,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
lastMessage.isProtected = isProtected
if (isAutoAnswered) {
lastMessage.isAnswered = true
lastMessage.autoApprovalDecision = autoApprovalDecision
}
await this.saveClineMessages()
// Fire-and-forget: see updateClineMessage call above for the
Expand All @@ -1292,6 +1294,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
text,
isProtected,
isAnswered: isAutoAnswered || undefined,
autoApprovalDecision,
})
}
}
Expand All @@ -1309,6 +1312,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
text,
isProtected,
isAnswered: isAutoAnswered || undefined,
autoApprovalDecision,
})
}

Expand Down
47 changes: 44 additions & 3 deletions src/core/tools/ExecuteCommandTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ interface ExecuteCommandParams {
timeout?: number | null
}

export function formatDcgBlockedMessage(reason?: string, ruleId?: string): string {
if (reason && ruleId) {
return t("tools:executeCommand.destructiveCommandGuard.blockedWithReasonAndRule", { reason, ruleId })
}

if (reason) {
return t("tools:executeCommand.destructiveCommandGuard.blockedWithReason", { reason })
}

if (ruleId) {
return t("tools:executeCommand.destructiveCommandGuard.blockedWithRule", { ruleId })
}

return t("tools:executeCommand.destructiveCommandGuard.blocked")
}

export function resolveAgentTimeoutMs(timeoutSeconds: number | null | undefined): number {
const requestedAgentTimeout = typeof timeoutSeconds === "number" && timeoutSeconds > 0 ? timeoutSeconds * 1000 : 0

Expand Down Expand Up @@ -115,16 +131,41 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
return
}

const didApprove = await askApproval("command", canonicalCommand)
const provider = await task.providerRef.deref()
let dcgBlocked = false
if (provider?.contextProxy.getValue("destructiveCommandGuardEnabled") === true) {
const { ensureDcgInstalled, runDcg } = await import("../../services/destructive-command-guard")
// Resolve through the managed installer on use so an extension update
// automatically installs the newly pinned and verified DCG version.
const binaryPath = await ensureDcgInstalled(provider.context.globalStorageUri.fsPath)
if (!binaryPath) {
throw new Error(t("common:errors.destructiveCommandGuard.unavailable"))
}
const workingDirectory = customCwd
? path.isAbsolute(customCwd)
? customCwd
: path.resolve(task.cwd, customCwd)
: task.cwd
const dcgResult = await runDcg(binaryPath, canonicalCommand, workingDirectory)
dcgBlocked = dcgResult.decision === "deny"
if (dcgResult.decision === "deny") {
await task.say("error", formatDcgBlockedMessage(dcgResult.reason, dcgResult.ruleId))
}
}

// DCG-approved commands are auto-approved by checkAutoApproval. A DCG
// block is presented as Zoo's normal command prompt, with isProtected
// forcing the user to explicitly choose whether to execute it.
const didApprove = dcgBlocked
? await askApproval("command", canonicalCommand, undefined, true)
: await askApproval("command", canonicalCommand)

if (!didApprove) {
return
}

const executionId = task.lastMessageTs?.toString() ?? Date.now().toString()
const provider = await task.providerRef.deref()
const providerState = await provider?.getState()

const { terminalShellIntegrationDisabled = true } = providerState ?? {}

// Get command execution timeout from VSCode configuration (in seconds)
Expand Down
Loading
Loading