Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export const DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES = false
*/
export const DEFAULT_DIFF_FUZZY_THRESHOLD = 1.0

export const DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED = false

/**
* Terminal output preview size options for persisted command output.
*
Expand Down Expand Up @@ -136,6 +138,7 @@ export const globalSettingsSchema = z.object({
alwaysAllowModeSwitch: z.boolean().optional(),
alwaysAllowSubtasks: z.boolean().optional(),
alwaysAllowExecute: z.boolean().optional(),
destructiveCommandGuardEnabled: z.boolean().optional(),
alwaysAllowFollowupQuestions: z.boolean().optional(),
followupAutoApproveTimeoutMs: z.number().optional(),
allowedCommands: z.array(z.string()).optional(),
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
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ export type ExtensionState = Pick<
| "alwaysAllowSubtasks"
| "alwaysAllowFollowupQuestions"
| "alwaysAllowExecute"
| "destructiveCommandGuardEnabled"
| "followupAutoApproveTimeoutMs"
| "allowedCommands"
| "deniedCommands"
Expand Down
69 changes: 69 additions & 0 deletions src/core/auto-approval/__tests__/dcg.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { checkAutoApproval } from ".."

describe("Destructive Command Guard auto-approval precedence", () => {
const baseState = {
autoApprovalEnabled: true,
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("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",
})
})
})
11 changes: 11 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,18 @@ 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.
if (state.destructiveCommandGuardEnabled === true) {
return { decision: "approve" }
}

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 @@ -1185,6 +1185,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 @@ -1248,6 +1249,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 @@ -1269,6 +1271,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
text,
isProtected,
isAnswered: isAutoAnswered || undefined,
autoApprovalDecision,
})
}
}
Expand All @@ -1286,6 +1289,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
text,
isProtected,
isAnswered: isAutoAnswered || undefined,
autoApprovalDecision,
})
}

Expand Down
52 changes: 48 additions & 4 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,44 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
return
}

const didApprove = await askApproval("command", canonicalCommand)
const provider = await task.providerRef.deref()
const providerState = await provider?.getState()
let dcgBlocked = false
if (providerState?.destructiveCommandGuardEnabled === true) {
const { ensureDcgInstalled, runDcg } = await import("../../services/destructive-command-guard")
if (!provider) {
throw new Error(t("common:errors.destructiveCommandGuard.unavailable"))
}
// 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))
}
}
Comment thread
zoomote[bot] marked this conversation as resolved.

// 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