From a81fadeae8634413c2a0e7323bdd6f32e3a76fa7 Mon Sep 17 00:00:00 2001 From: Naved Date: Tue, 28 Jul 2026 18:45:27 -0700 Subject: [PATCH 01/12] Auto approve all commands except denied commands --- AGENTS.md | 16 +++ packages/types/src/global-settings.ts | 1 + packages/types/src/vscode-extension-host.ts | 1 + .../auto-approval/__tests__/commands.spec.ts | 22 ++++ src/core/auto-approval/commands.ts | 9 +- src/core/auto-approval/index.ts | 8 +- src/core/webview/ClineProvider.ts | 3 + .../webview/__tests__/ClineProvider.spec.ts | 9 ++ .../settings/AutoApproveSettings.tsx | 110 +++++++++++------- .../src/components/settings/SettingsView.tsx | 3 + .../__tests__/AutoApproveSettings.spec.tsx | 16 +++ webview-ui/src/i18n/locales/ca/settings.json | 4 + webview-ui/src/i18n/locales/de/settings.json | 4 + webview-ui/src/i18n/locales/en/settings.json | 4 + webview-ui/src/i18n/locales/es/settings.json | 4 + webview-ui/src/i18n/locales/fr/settings.json | 4 + webview-ui/src/i18n/locales/hi/settings.json | 4 + webview-ui/src/i18n/locales/id/settings.json | 4 + webview-ui/src/i18n/locales/it/settings.json | 4 + webview-ui/src/i18n/locales/ja/settings.json | 4 + webview-ui/src/i18n/locales/ko/settings.json | 4 + webview-ui/src/i18n/locales/nl/settings.json | 4 + webview-ui/src/i18n/locales/pl/settings.json | 4 + .../src/i18n/locales/pt-BR/settings.json | 4 + webview-ui/src/i18n/locales/ru/settings.json | 4 + webview-ui/src/i18n/locales/tr/settings.json | 4 + webview-ui/src/i18n/locales/vi/settings.json | 4 + .../src/i18n/locales/zh-CN/settings.json | 4 + .../src/i18n/locales/zh-TW/settings.json | 4 + 29 files changed, 227 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 64085dc087..1b70c7347b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,22 @@ When writing new code: - After editing a file, run `pnpm --dir src exec eslint --prune-suppressions --max-warnings=0 ` 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. diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 88b5408fca..fe75a1c9e1 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -136,6 +136,7 @@ export const globalSettingsSchema = z.object({ alwaysAllowModeSwitch: z.boolean().optional(), alwaysAllowSubtasks: z.boolean().optional(), alwaysAllowExecute: z.boolean().optional(), + alwaysAllowCommandsExceptDenied: z.boolean().optional(), alwaysAllowFollowupQuestions: z.boolean().optional(), followupAutoApproveTimeoutMs: z.number().optional(), allowedCommands: z.array(z.string()).optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index c35a5da538..2338c664a3 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -274,6 +274,7 @@ export type ExtensionState = Pick< | "alwaysAllowSubtasks" | "alwaysAllowFollowupQuestions" | "alwaysAllowExecute" + | "alwaysAllowCommandsExceptDenied" | "followupAutoApproveTimeoutMs" | "allowedCommands" | "deniedCommands" diff --git a/src/core/auto-approval/__tests__/commands.spec.ts b/src/core/auto-approval/__tests__/commands.spec.ts index fa5762cb56..88c695abf8 100644 --- a/src/core/auto-approval/__tests__/commands.spec.ts +++ b/src/core/auto-approval/__tests__/commands.spec.ts @@ -36,6 +36,28 @@ describe("getCommandDecision", () => { const result = getCommandDecision(command, ["*"]) expect(result).toBe("auto_approve") }) + + describe("allow all except denied mode", () => { + it("auto-approves a command that is not in the allowlist or denylist", () => { + expect(getCommandDecision("unknown command", [], ["rm"], true)).toBe("auto_approve") + }) + + it("auto-denies a command matching the denylist even if a longer allowlist entry matches", () => { + expect(getCommandDecision("rm --dry-run file", ["rm --dry-run"], ["rm"], true)).toBe("auto_deny") + }) + + it("auto-denies a command chain when any sub-command matches the denylist", () => { + expect(getCommandDecision("git status && rm file", [], ["rm"], true)).toBe("auto_deny") + }) + + it("preserves dangerous substitution protection", () => { + expect(getCommandDecision('echo "${var@P}"', [], [], true)).toBe("ask_user") + }) + + it("preserves malformed command protection", () => { + expect(getCommandDecision("sh -c 'echo a", [], [], true)).toBe("malformed_command") + }) + }) }) describe("containsDangerousSubstitution — node -e one-liner false positive regression", () => { diff --git a/src/core/auto-approval/commands.ts b/src/core/auto-approval/commands.ts index 82b937e66f..b9bc5719f5 100644 --- a/src/core/auto-approval/commands.ts +++ b/src/core/auto-approval/commands.ts @@ -217,7 +217,8 @@ export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user" | "malfo * **Decision Logic:** * 1. **Dangerous Substitution Protection**: Commands with dangerous parameter expansions are never auto-approved * 2. **Command Parsing**: Split command chains (&&, ||, ;, |, &) into individual commands - * 3. **Individual Validation**: For each sub-command, apply longest prefix match rule + * 3. **Individual Validation**: For each sub-command, either apply the longest prefix match rule or, when + * `allowAllExceptDenied` is enabled, approve it unless it matches the denylist * 4. **Aggregation**: Combine decisions using "any denial blocks all" principle * * **Return Values:** @@ -252,12 +253,14 @@ export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user" | "malfo * @param command - The full command string to validate * @param allowedCommands - List of allowed command prefixes * @param deniedCommands - Optional list of denied command prefixes + * @param allowAllExceptDenied - Auto-approve commands without a denylist match, ignoring the allowlist * @returns Decision indicating whether to approve, deny, or ask user */ export function getCommandDecision( command: string, allowedCommands: string[], deniedCommands?: string[], + allowAllExceptDenied = false, ): CommandDecision { if (!command?.trim()) { return "auto_approve" @@ -282,6 +285,10 @@ export function getCommandDecision( // Remove simple PowerShell-like redirections (e.g. 2>&1) before checking const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim() + if (allowAllExceptDenied) { + return findLongestPrefixMatch(cmdWithoutRedirection, deniedCommands || []) ? "auto_deny" : "auto_approve" + } + return getSingleCommandDecision(cmdWithoutRedirection, allowedCommands, deniedCommands) }) diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index c8293c2a79..36f4feed64 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -33,6 +33,7 @@ export type AutoApprovalStateOptions = | "mcpServers" // For `alwaysAllowMcp`. | "allowedCommands" // For `alwaysAllowExecute`. | "deniedCommands" + | "alwaysAllowCommandsExceptDenied" export type CheckAutoApprovalResult = | { decision: "approve" } @@ -117,7 +118,12 @@ export async function checkAutoApproval({ } if (state.alwaysAllowExecute === true) { - const decision = getCommandDecision(text, state.allowedCommands || [], state.deniedCommands || []) + const decision = getCommandDecision( + text, + state.allowedCommands || [], + state.deniedCommands || [], + state.alwaysAllowCommandsExceptDenied === true, + ) if (decision === "auto_approve") { return { decision: "approve" } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 2ee92edebc..f6236ba2f9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2310,6 +2310,7 @@ export class ClineProvider alwaysAllowWriteOutsideWorkspace, alwaysAllowWriteProtected, alwaysAllowExecute, + alwaysAllowCommandsExceptDenied, allowedCommands, deniedCommands, alwaysAllowMcp, @@ -2459,6 +2460,7 @@ export class ClineProvider alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false, alwaysAllowExecute: alwaysAllowExecute ?? false, + alwaysAllowCommandsExceptDenied: alwaysAllowCommandsExceptDenied ?? false, alwaysAllowMcp: alwaysAllowMcp ?? false, alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: alwaysAllowSubtasks ?? false, @@ -2691,6 +2693,7 @@ export class ClineProvider alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, + alwaysAllowCommandsExceptDenied: stateValues.alwaysAllowCommandsExceptDenied ?? false, alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b99e502f61..762d36b7a4 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1011,6 +1011,15 @@ describe("ClineProvider", () => { expect(state).toHaveProperty("writeDelayMs") }) + test("getStateToPostToWebview returns the saved allow-all-except-denied setting", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("alwaysAllowCommandsExceptDenied", true) + + const state = await provider.getStateToPostToWebview() + + expect(state.alwaysAllowCommandsExceptDenied).toBe(true) + }) + test("language is set to VSCode language", async () => { // Mock VSCode language as Spanish ;(vscode.env as any).language = "pt-BR" diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 90b82dc4be..34d4aea775 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -28,6 +28,7 @@ type AutoApproveSettingsProps = HTMLAttributes & { alwaysAllowModeSwitch?: boolean alwaysAllowSubtasks?: boolean alwaysAllowExecute?: boolean + alwaysAllowCommandsExceptDenied?: boolean alwaysAllowFollowupQuestions?: boolean followupAutoApproveTimeoutMs?: number allowedCommands?: string[] @@ -44,6 +45,7 @@ type AutoApproveSettingsProps = HTMLAttributes & { | "alwaysAllowModeSwitch" | "alwaysAllowSubtasks" | "alwaysAllowExecute" + | "alwaysAllowCommandsExceptDenied" | "alwaysAllowFollowupQuestions" | "followupAutoApproveTimeoutMs" | "allowedCommands" @@ -63,6 +65,7 @@ export const AutoApproveSettings = ({ alwaysAllowModeSwitch, alwaysAllowSubtasks, alwaysAllowExecute, + alwaysAllowCommandsExceptDenied, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs = 60000, allowedCommands, @@ -276,53 +279,78 @@ export const AutoApproveSettings = ({ - + label={t("settings:autoApprove.execute.allowAllExceptDenied.label")}> + + setCachedStateField("alwaysAllowCommandsExceptDenied", e.target.checked) + } + data-testid="always-allow-commands-except-denied-checkbox"> + + {t("settings:autoApprove.execute.allowAllExceptDenied.label")} + +
- {t("settings:autoApprove.execute.allowedCommandsDescription")} + {t("settings:autoApprove.execute.allowAllExceptDenied.description")}
-
- setCommandInput(e.target.value)} - onKeyDown={(e: any) => { - if (e.key === "Enter") { - e.preventDefault() - handleAddCommand() - } - }} - placeholder={t("settings:autoApprove.execute.commandPlaceholder")} - className="grow" - data-testid="command-input" - /> - -
- -
- {(allowedCommands ?? []).map((cmd, index) => ( - - ))} -
+ + +
+ setCommandInput(e.target.value)} + onKeyDown={(e: any) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddCommand() + } + }} + placeholder={t("settings:autoApprove.execute.commandPlaceholder")} + className="grow" + data-testid="command-input" + /> + +
+ +
+ {(allowedCommands ?? []).map((cmd, index) => ( + + ))} +
+ + )} {/* Denied Commands Section */} (({ onDone, t allowedMaxCost, language, alwaysAllowExecute, + alwaysAllowCommandsExceptDenied, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, @@ -387,6 +388,7 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? undefined, alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? undefined, alwaysAllowExecute: alwaysAllowExecute ?? undefined, + alwaysAllowCommandsExceptDenied: alwaysAllowCommandsExceptDenied ?? false, alwaysAllowMcp, alwaysAllowModeSwitch, allowedCommands: allowedCommands ?? [], @@ -814,6 +816,7 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowModeSwitch={alwaysAllowModeSwitch} alwaysAllowSubtasks={alwaysAllowSubtasks} alwaysAllowExecute={alwaysAllowExecute} + alwaysAllowCommandsExceptDenied={alwaysAllowCommandsExceptDenied} alwaysAllowFollowupQuestions={alwaysAllowFollowupQuestions} followupAutoApproveTimeoutMs={followupAutoApproveTimeoutMs} allowedCommands={allowedCommands} diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx index 8b736280ee..56c1e1b279 100644 --- a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx @@ -97,4 +97,20 @@ describe("AutoApproveSettings - Save/Discard contract", () => { expect(setCachedStateField).toHaveBeenCalledWith("deniedCommands", []) expectNoImmediateUpdateSettings() }) + + it("buffers the allow-all-except-denied setting without persisting before Save", () => { + const { setCachedStateField } = renderSettings() + + fireEvent.click(screen.getByTestId("always-allow-commands-except-denied-checkbox")) + + expect(setCachedStateField).toHaveBeenCalledWith("alwaysAllowCommandsExceptDenied", true) + expectNoImmediateUpdateSettings() + }) + + it("hides the allowed command list when allow-all-except-denied is enabled", () => { + renderSettings({ alwaysAllowCommandsExceptDenied: true }) + + expect(screen.queryByTestId("allowed-commands-heading")).not.toBeInTheDocument() + expect(screen.getByTestId("denied-commands-heading")).toBeInTheDocument() + }) }) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index b9644e0d12..b959721985 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "Executar", "description": "Executar automàticament comandes de terminal permeses sense requerir aprovació", + "allowAllExceptDenied": { + "label": "Aprova automàticament totes les comandes excepte les denegades", + "description": "Aprova automàticament qualsevol comanda que no coincideixi amb el prefix d'una comanda denegada. La llista de comandes permeses s'ignora mentre aquesta opció està activada. Les comandes malformades i les substitucions perilloses continuen requerint revisió." + }, "allowedCommands": "Comandes d'auto-execució permeses", "allowedCommandsDescription": "Prefixos de comandes que poden ser executats automàticament quan \"Aprovar sempre operacions d'execució\" està habilitat. Afegeix * per permetre totes les comandes (usar amb precaució).", "deniedCommands": "Comandes denegades", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 46a8bd28e4..e16ff08b4e 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "Ausführen", "description": "Erlaubte Terminal-Befehle automatisch ohne Genehmigung ausführen", + "allowAllExceptDenied": { + "label": "Alle Befehle außer verweigerten automatisch genehmigen", + "description": "Genehmigt automatisch jeden Befehl, der mit keinem Präfix eines verweigerten Befehls übereinstimmt. Die Liste erlaubter Befehle wird ignoriert, solange diese Einstellung aktiviert ist. Fehlerhafte Befehle und gefährliche Ersetzungen müssen weiterhin geprüft werden." + }, "allowedCommands": "Erlaubte Auto-Ausführungsbefehle", "allowedCommandsDescription": "Befehlspräfixe, die automatisch ausgeführt werden können, wenn 'Ausführungsoperationen immer genehmigen' aktiviert ist. Fügen Sie * hinzu, um alle Befehle zu erlauben (mit Vorsicht verwenden).", "deniedCommands": "Verweigerte Befehle", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 37d33f8172..adf39a08ff 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -406,6 +406,10 @@ "execute": { "label": "Execute", "description": "Automatically execute allowed terminal commands without requiring approval", + "allowAllExceptDenied": { + "label": "Auto-approve all commands except denied commands", + "description": "Auto-approve any command that does not match a denied command prefix. The allowed commands list is ignored while this setting is enabled. Malformed commands and dangerous substitutions still require review." + }, "allowedCommands": "Allowed Auto-Execute Commands", "allowedCommandsDescription": "Command prefixes that can be auto-executed when \"Always approve execute operations\" is enabled. Add * to allow all commands (use with caution).", "deniedCommands": "Denied Commands", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index f9c1be76f4..fe4b35abed 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "Ejecutar", "description": "Ejecutar automáticamente comandos de terminal permitidos sin requerir aprobación", + "allowAllExceptDenied": { + "label": "Autoaprobar todos los comandos excepto los denegados", + "description": "Aprueba automáticamente cualquier comando que no coincida con el prefijo de un comando denegado. La lista de comandos permitidos se ignora mientras esta opción esté habilitada. Los comandos con formato incorrecto y las sustituciones peligrosas aún requieren revisión." + }, "allowedCommands": "Comandos de auto-ejecución permitidos", "allowedCommandsDescription": "Prefijos de comandos que pueden ser ejecutados automáticamente cuando \"Aprobar siempre operaciones de ejecución\" está habilitado. Añade * para permitir todos los comandos (usar con precaución).", "deniedCommands": "Comandos denegados", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 6da211144b..7613be6e0d 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -329,6 +329,10 @@ "execute": { "label": "Exécuter", "description": "Exécuter automatiquement les commandes de terminal autorisées sans nécessiter d'approbation", + "allowAllExceptDenied": { + "label": "Approuver automatiquement toutes les commandes sauf celles refusées", + "description": "Approuve automatiquement toute commande qui ne correspond pas au préfixe d'une commande refusée. La liste des commandes autorisées est ignorée tant que ce paramètre est activé. Les commandes mal formées et les substitutions dangereuses nécessitent toujours une vérification." + }, "allowedCommands": "Commandes auto-exécutables autorisées", "allowedCommandsDescription": "Préfixes de commandes qui peuvent être auto-exécutés lorsque \"Toujours approuver les opérations d'exécution\" est activé. Ajoutez * pour autoriser toutes les commandes (à utiliser avec précaution).", "deniedCommands": "Commandes refusées", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 522c68a586..56a10c50f3 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "निष्पादित करें", "description": "अनुमोदन की आवश्यकता के बिना स्वचालित रूप से अनुमत टर्मिनल कमांड निष्पादित करें", + "allowAllExceptDenied": { + "label": "अस्वीकृत कमांड को छोड़कर सभी कमांड स्वतः अनुमोदित करें", + "description": "ऐसे किसी भी कमांड को स्वतः अनुमोदित करता है जो किसी अस्वीकृत कमांड प्रीफिक्स से मेल नहीं खाता। यह सेटिंग सक्षम होने पर अनुमत कमांड की सूची अनदेखी की जाती है। गलत संरचना वाले कमांड और खतरनाक प्रतिस्थापन की अब भी समीक्षा आवश्यक है।" + }, "allowedCommands": "अनुमत स्वतः-निष्पादन कमांड", "allowedCommandsDescription": "कमांड प्रीफिक्स जो स्वचालित रूप से निष्पादित किए जा सकते हैं जब \"निष्पादन ऑपरेशन हमेशा अनुमोदित करें\" सक्षम है। सभी कमांड की अनुमति देने के लिए * जोड़ें (सावधानी से उपयोग करें)।", "deniedCommands": "अस्वीकृत कमांड", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index b2943e63bc..ef4b2751e6 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "Eksekusi", "description": "Secara otomatis mengeksekusi perintah terminal yang diizinkan tanpa memerlukan persetujuan", + "allowAllExceptDenied": { + "label": "Setujui otomatis semua perintah kecuali perintah yang ditolak", + "description": "Setujui otomatis setiap perintah yang tidak cocok dengan prefiks perintah yang ditolak. Daftar perintah yang diizinkan akan diabaikan selama pengaturan ini aktif. Perintah dengan format yang salah dan substitusi berbahaya tetap memerlukan peninjauan." + }, "allowedCommands": "Perintah Auto-Execute yang Diizinkan", "allowedCommandsDescription": "Prefix perintah yang dapat di-auto-execute ketika \"Selalu setujui operasi eksekusi\" diaktifkan. Tambahkan * untuk mengizinkan semua perintah (gunakan dengan hati-hati).", "deniedCommands": "Perintah yang ditolak", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 6bd638c796..1a8e3fc50a 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "Esegui", "description": "Esegui automaticamente i comandi del terminale consentiti senza richiedere approvazione", + "allowAllExceptDenied": { + "label": "Approva automaticamente tutti i comandi tranne quelli negati", + "description": "Approva automaticamente qualsiasi comando che non corrisponde al prefisso di un comando negato. L'elenco dei comandi consentiti viene ignorato mentre questa impostazione è attiva. I comandi non validi e le sostituzioni pericolose richiedono comunque una revisione." + }, "allowedCommands": "Comandi di auto-esecuzione consentiti", "allowedCommandsDescription": "Prefissi di comando che possono essere auto-eseguiti quando \"Approva sempre operazioni di esecuzione\" è abilitato. Aggiungi * per consentire tutti i comandi (usare con cautela).", "deniedCommands": "Comandi negati", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 96fba3521a..ac7bb6fa1a 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "実行", "description": "承認なしで自動的に許可されたターミナルコマンドを実行", + "allowAllExceptDenied": { + "label": "拒否されたコマンド以外をすべて自動承認", + "description": "拒否されたコマンドのプレフィックスに一致しないコマンドを自動承認します。この設定が有効な間、許可されたコマンドのリストは無視されます。不正な形式のコマンドや危険な置換は、引き続き確認が必要です。" + }, "allowedCommands": "許可された自動実行コマンド", "allowedCommandsDescription": "「実行操作を常に承認」が有効な場合に自動実行できるコマンドプレフィックス。すべてのコマンドを許可するには * を追加します(注意して使用してください)。", "deniedCommands": "拒否されたコマンド", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 40313a32f5..154265fa59 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "실행", "description": "승인 없이 자동으로 허용된 터미널 명령 실행", + "allowAllExceptDenied": { + "label": "거부된 명령을 제외한 모든 명령 자동 승인", + "description": "거부된 명령 접두사와 일치하지 않는 모든 명령을 자동 승인합니다. 이 설정이 활성화된 동안에는 허용된 명령 목록이 무시됩니다. 형식이 잘못된 명령과 위험한 치환은 계속 검토가 필요합니다." + }, "allowedCommands": "허용된 자동 실행 명령", "allowedCommandsDescription": "\"실행 작업 항상 승인\"이 활성화되었을 때 자동 실행될 수 있는 명령 접두사. 모든 명령을 허용하려면 * 추가(주의해서 사용)", "deniedCommands": "거부된 명령", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 6664eb3d74..067a4119ae 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "Uitvoeren", "description": "Automatisch toegestane terminalcommando's uitvoeren zonder goedkeuring", + "allowAllExceptDenied": { + "label": "Alle commando's behalve geweigerde automatisch goedkeuren", + "description": "Keurt elk commando automatisch goed dat niet overeenkomt met het voorvoegsel van een geweigerd commando. De lijst met toegestane commando's wordt genegeerd zolang deze instelling is ingeschakeld. Onjuist gevormde commando's en gevaarlijke vervangingen moeten nog steeds worden beoordeeld." + }, "allowedCommands": "Toegestane automatisch uit te voeren commando's", "allowedCommandsDescription": "Commando-prefixen die automatisch kunnen worden uitgevoerd als 'Altijd goedkeuren voor uitvoeren' is ingeschakeld. Voeg * toe om alle commando's toe te staan (gebruik met voorzichtigheid).", "deniedCommands": "Geweigerde commando's", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 7a7ab3dcb0..8e60c732ff 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "Wykonaj", "description": "Automatycznie wykonuj dozwolone polecenia terminala bez konieczności zatwierdzania", + "allowAllExceptDenied": { + "label": "Automatycznie zatwierdzaj wszystkie polecenia poza odrzuconymi", + "description": "Automatycznie zatwierdza każde polecenie, które nie pasuje do prefiksu odrzuconego polecenia. Lista dozwolonych poleceń jest ignorowana, gdy to ustawienie jest włączone. Nieprawidłowo sformułowane polecenia i niebezpieczne podstawienia nadal wymagają sprawdzenia." + }, "allowedCommands": "Dozwolone polecenia auto-wykonania", "allowedCommandsDescription": "Prefiksy poleceń, które mogą być automatycznie wykonywane, gdy \"Zawsze zatwierdzaj operacje wykonania\" jest włączone. Dodaj * aby zezwolić na wszystkie polecenia (używaj z ostrożnością).", "deniedCommands": "Odrzucone polecenia", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index bb44c1bcaa..2cf3a67871 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "Executar", "description": "Executar automaticamente comandos de terminal permitidos sem exigir aprovação", + "allowAllExceptDenied": { + "label": "Aprovar automaticamente todos os comandos, exceto os negados", + "description": "Aprova automaticamente qualquer comando que não corresponda ao prefixo de um comando negado. A lista de comandos permitidos é ignorada enquanto esta configuração estiver ativada. Comandos malformados e substituições perigosas ainda exigem revisão." + }, "allowedCommands": "Comandos de auto-execução permitidos", "allowedCommandsDescription": "Prefixos de comando que podem ser auto-executados quando \"Aprovar sempre operações de execução\" está ativado. Adicione * para permitir todos os comandos (use com cautela).", "deniedCommands": "Comandos negados", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index ee02578534..887b601cf5 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "Выполнение", "description": "Автоматически выполнять разрешённые команды терминала без необходимости одобрения", + "allowAllExceptDenied": { + "label": "Автоматически одобрять все команды, кроме запрещённых", + "description": "Автоматически одобряет любую команду, которая не совпадает с префиксом запрещённой команды. Пока эта настройка включена, список разрешённых команд игнорируется. Некорректные команды и опасные подстановки по-прежнему требуют проверки." + }, "allowedCommands": "Разрешённые авто-выполняемые команды", "allowedCommandsDescription": "Префиксы команд, которые могут быть автоматически выполнены при включённом параметре \"Всегда одобрять выполнение операций\". Добавьте * для разрешения всех команд (используйте с осторожностью).", "deniedCommands": "Запрещенные команды", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index e37c154ead..1b2fbfc294 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "Yürüt", "description": "Onay gerektirmeden otomatik olarak izin verilen terminal komutlarını yürüt", + "allowAllExceptDenied": { + "label": "Reddedilenler dışındaki tüm komutları otomatik onayla", + "description": "Reddedilen bir komut önekiyle eşleşmeyen tüm komutları otomatik olarak onaylar. Bu ayar etkinken izin verilen komutlar listesi yok sayılır. Hatalı biçimlendirilmiş komutlar ve tehlikeli değiştirmeler yine de inceleme gerektirir." + }, "allowedCommands": "İzin Verilen Otomatik Yürütme Komutları", "allowedCommandsDescription": "\"Yürütme işlemlerini her zaman onayla\" etkinleştirildiğinde otomatik olarak yürütülebilen komut önekleri. Tüm komutlara izin vermek için * ekleyin (dikkatli kullanın).", "deniedCommands": "Reddedilen komutlar", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index c3b40a004a..1eb87f312e 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "Thực thi", "description": "Tự động thực thi các lệnh terminal được phép mà không cần phê duyệt", + "allowAllExceptDenied": { + "label": "Tự động phê duyệt mọi lệnh ngoại trừ các lệnh bị từ chối", + "description": "Tự động phê duyệt mọi lệnh không khớp với tiền tố của lệnh bị từ chối. Danh sách lệnh được phép sẽ bị bỏ qua khi cài đặt này được bật. Các lệnh sai định dạng và phép thay thế nguy hiểm vẫn cần được xem xét." + }, "allowedCommands": "Các lệnh tự động thực thi được phép", "allowedCommandsDescription": "Tiền tố lệnh có thể được tự động thực thi khi \"Luôn phê duyệt các hoạt động thực thi\" được bật. Thêm * để cho phép tất cả các lệnh (sử dụng cẩn thận).", "deniedCommands": "Lệnh bị từ chối", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 2e8abf435e..0d774e42fb 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -328,6 +328,10 @@ "execute": { "label": "执行", "description": "自动执行白名单中的命令而无需批准", + "allowAllExceptDenied": { + "label": "自动批准除拒绝命令之外的所有命令", + "description": "自动批准所有不匹配拒绝命令前缀的命令。启用此设置时,将忽略允许命令列表。格式错误的命令和危险的替换仍需审核。" + }, "allowedCommands": "命令白名单", "allowedCommandsDescription": "当\"自动批准命令行操作\"启用时可以自动执行的命令前缀。添加 * 以允许所有命令(谨慎使用)。", "deniedCommands": "拒绝的命令", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 481bab5301..6454b55543 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -353,6 +353,10 @@ "execute": { "label": "執行命令", "description": "無需核准即可自動執行允許的終端機命令", + "allowAllExceptDenied": { + "label": "自動核准拒絕命令以外的所有命令", + "description": "自動核准所有不符合拒絕命令前綴的命令。啟用此設定時,允許的命令清單將被忽略。格式錯誤的命令和危險的替換仍需審查。" + }, "allowedCommands": "允許自動執行的命令", "allowedCommandsDescription": "啟用「始終核准執行」時,可自動執行的命令前綴。新增 * 可允許所有命令(請謹慎使用)。", "deniedCommands": "拒絕的命令", From 1c9db212d5969a16928a096032c87e006aa2c4c2 Mon Sep 17 00:00:00 2001 From: Naved Date: Tue, 28 Jul 2026 21:35:09 -0700 Subject: [PATCH 02/12] auto approve UX improvements --- packages/types/src/message.ts | 1 + src/core/task/Task.ts | 4 ++ webview-ui/src/components/chat/ChatRow.tsx | 10 +++ .../src/components/chat/CommandExecution.tsx | 6 +- .../__tests__/ChatRow.command-denied.spec.tsx | 70 +++++++++++++++++++ .../chat/__tests__/CommandExecution.spec.tsx | 26 +++++++ webview-ui/src/i18n/locales/ca/chat.json | 1 + webview-ui/src/i18n/locales/de/chat.json | 1 + webview-ui/src/i18n/locales/en/chat.json | 1 + webview-ui/src/i18n/locales/es/chat.json | 1 + webview-ui/src/i18n/locales/fr/chat.json | 1 + webview-ui/src/i18n/locales/hi/chat.json | 1 + webview-ui/src/i18n/locales/id/chat.json | 1 + webview-ui/src/i18n/locales/it/chat.json | 1 + webview-ui/src/i18n/locales/ja/chat.json | 1 + webview-ui/src/i18n/locales/ko/chat.json | 1 + webview-ui/src/i18n/locales/nl/chat.json | 1 + webview-ui/src/i18n/locales/pl/chat.json | 1 + webview-ui/src/i18n/locales/pt-BR/chat.json | 1 + webview-ui/src/i18n/locales/ru/chat.json | 1 + webview-ui/src/i18n/locales/tr/chat.json | 1 + webview-ui/src/i18n/locales/vi/chat.json | 1 + webview-ui/src/i18n/locales/zh-CN/chat.json | 1 + webview-ui/src/i18n/locales/zh-TW/chat.json | 1 + 24 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 webview-ui/src/components/chat/__tests__/ChatRow.command-denied.spec.tsx diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index dc0e3dfff5..28d5af82ac 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -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 diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 8f69a3a0d4..c5127feb17 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1185,6 +1185,7 @@ export class Task extends EventEmitter 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) @@ -1248,6 +1249,7 @@ export class Task extends EventEmitter 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 @@ -1269,6 +1271,7 @@ export class Task extends EventEmitter implements TaskLike { text, isProtected, isAnswered: isAutoAnswered || undefined, + autoApprovalDecision, }) } } @@ -1286,6 +1289,7 @@ export class Task extends EventEmitter implements TaskLike { text, isProtected, isAnswered: isAutoAnswered || undefined, + autoApprovalDecision, }) } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index f973c7929f..588af55125 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -72,6 +72,7 @@ import { Split, ArrowRight, Check, + OctagonX, } from "lucide-react" import { cn } from "@/lib/utils" import { PathTooltip } from "../ui/PathTooltip" @@ -290,6 +291,14 @@ export const ChatRowContent = ({ case "mistake_limit_reached": return [null, null] // These will be handled by ErrorRow component case "command": + if (message.autoApprovalDecision === "deny") { + return [ + , + + {t("chat:commandExecution.denied")} + , + ] + } return [ isCommandExecuting ? ( @@ -1603,6 +1612,7 @@ export const ChatRowContent = ({ text={message.text} icon={icon} title={title} + isDenied={message.autoApprovalDecision === "deny"} /> ) case "use_mcp_server": diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index b5c38cd9ef..b6dda274ef 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -36,11 +36,13 @@ interface CommandExecutionProps { text?: string icon?: JSX.Element | null title?: JSX.Element | null + isDenied?: boolean } -export const CommandExecution = ({ executionId, text, icon, title }: CommandExecutionProps) => { +export const CommandExecution = ({ executionId, text, icon, title, isDenied = false }: CommandExecutionProps) => { const { terminalShellIntegrationDisabled = false, + alwaysAllowCommandsExceptDenied = false, allowedCommands = [], deniedCommands = [], setAllowedCommands, @@ -245,7 +247,7 @@ export const CommandExecution = ({ executionId, text, icon, title }: CommandExec - {command && command.trim() && ( + {command && command.trim() && !alwaysAllowCommandsExceptDenied && !isDenied && ( ({ + useTranslation: () => ({ t: (key: string) => key }), + Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => <>{children || i18nKey}, + initReactI18next: { type: "3rdParty", init: () => {} }, +})) + +vi.mock("../CommandExecution", () => ({ + CommandExecution: ({ text, title, isDenied }: { text?: string; title?: React.ReactNode; isDenied?: boolean }) => ( +
+ {title} + {text} +
+ ), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeBadge: ({ children, ...props }: { children: React.ReactNode }) => {children}, +})) + +const renderCommand = (autoApprovalDecision?: "approve" | "deny") => { + const queryClient = new QueryClient() + + return render( + + + + + , + ) +} + +describe("ChatRow - denied commands", () => { + it("shows a denied status for a command rejected by auto-approval", () => { + renderCommand("deny") + + expect(screen.getByText("chat:commandExecution.denied")).toBeInTheDocument() + expect(screen.getByTestId("command-execution")).toHaveAttribute("data-denied", "true") + }) + + it("does not show a denied status for an approved command", () => { + renderCommand("approve") + + expect(screen.queryByText("chat:commandExecution.denied")).not.toBeInTheDocument() + expect(screen.getByTestId("command-execution")).toHaveAttribute("data-denied", "false") + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx index 19d2ca2c5c..a6a64939dc 100644 --- a/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx @@ -110,6 +110,32 @@ describe("CommandExecution", () => { expect(selector).toHaveTextContent("npm install express") }) + it("should hide the command pattern selector when all commands are auto-approved except denied commands", () => { + const state = { + ...mockExtensionState, + alwaysAllowCommandsExceptDenied: true, + } + + render( + + + , + ) + + expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() + }) + + it("should hide the command pattern selector for a denied command", () => { + render( + + + , + ) + + expect(screen.getByTestId("code-block")).toHaveTextContent("rm -rf /tmp/example") + expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() + }) + it("should handle allow command change", () => { render( diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 11ac1d4028..6460895fd3 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -233,6 +233,7 @@ "exitStatus": "S'ha sortit amb l'estat {{exitCode}}", "malformedCommand": "Ordre mal formada: error de sintaxi del shell", "manageCommands": "Comandes aprovades automàticament", + "denied": "Ordre denegada", "addToAllowed": "Afegeix a la llista permesa", "removeFromAllowed": "Elimina de la llista permesa", "addToDenied": "Afegeix a la llista denegada", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 38415c87a5..a2201231bc 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -233,6 +233,7 @@ "exitStatus": "Beendet mit Status {{exitCode}}", "malformedCommand": "Fehlerhafter Befehl: Shell-Syntaxfehler", "manageCommands": "Automatisch genehmigte Befehle", + "denied": "Befehl abgelehnt", "addToAllowed": "Zur erlaubten Liste hinzufügen", "removeFromAllowed": "Von der erlaubten Liste entfernen", "addToDenied": "Zur verweigerten Liste hinzufügen", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 8df0c2938a..5775f2bee6 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -276,6 +276,7 @@ "exitStatus": "Exited with status {{exitCode}}", "malformedCommand": "Malformed command: shell syntax error", "manageCommands": "Auto-approved commands", + "denied": "Command denied", "addToAllowed": "Add to allowed list", "removeFromAllowed": "Remove from allowed list", "addToDenied": "Add to denied list", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index d3beb38fe0..b5e5f6bb24 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -233,6 +233,7 @@ "exitStatus": "Salió con el estado {{exitCode}}", "malformedCommand": "Comando mal formado: error de sintaxis del shell", "manageCommands": "Comandos aprobados automáticamente", + "denied": "Comando denegado", "addToAllowed": "Agregar a la lista de permitidos", "removeFromAllowed": "Eliminar de la lista de permitidos", "addToDenied": "Agregar a la lista de denegados", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 080e3cb044..73a1f523ee 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -233,6 +233,7 @@ "exitStatus": "Terminé avec le statut {{exitCode}}", "malformedCommand": "Commande mal formée : erreur de syntaxe shell", "manageCommands": "Commandes approuvées automatiquement", + "denied": "Commande refusée", "addToAllowed": "Ajouter à la liste autorisée", "removeFromAllowed": "Retirer de la liste autorisée", "addToDenied": "Ajouter à la liste refusée", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index f2ac1cb20d..c5a877a7b8 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -233,6 +233,7 @@ "exitStatus": "{{exitCode}} स्थिति के साथ बाहर निकल गया", "malformedCommand": "गलत कमांड: shell सिंटैक्स त्रुटि", "manageCommands": "स्वतः-अनुमोदित कमांड", + "denied": "कमांड अस्वीकृत", "addToAllowed": "अनुमत सूची में जोड़ें", "removeFromAllowed": "अनुमत सूची से हटाएँ", "addToDenied": "अस्वीकृत सूची में जोड़ें", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index e95a517221..f9b73ac16e 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -284,6 +284,7 @@ "exitStatus": "Keluar dengan status {{exitCode}}", "malformedCommand": "Perintah tidak valid: kesalahan sintaks shell", "manageCommands": "Perintah yang disetujui secara otomatis", + "denied": "Perintah ditolak", "addToAllowed": "Tambahkan ke daftar yang diizinkan", "removeFromAllowed": "Hapus dari daftar yang diizinkan", "addToDenied": "Tambahkan ke daftar yang ditolak", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 7085edee5b..772e4f4c6b 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -233,6 +233,7 @@ "exitStatus": "Uscito con stato {{exitCode}}", "malformedCommand": "Comando non valido: errore di sintassi della shell", "manageCommands": "Comandi approvati automaticamente", + "denied": "Comando negato", "addToAllowed": "Aggiungi alla lista dei permessi", "removeFromAllowed": "Rimuovi dalla lista dei permessi", "addToDenied": "Aggiungi alla lista dei negati", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 8abbe5792c..5c8897d63e 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -233,6 +233,7 @@ "exitStatus": "ステータス {{exitCode}} で終了しました", "malformedCommand": "不正なコマンド: シェル構文エラー", "manageCommands": "自動承認されたコマンド", + "denied": "コマンドが拒否されました", "addToAllowed": "許可リストに追加", "removeFromAllowed": "許可リストから削除", "addToDenied": "拒否リストに追加", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index ac7b6c4c05..93b9b99e09 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -233,6 +233,7 @@ "exitStatus": "상태 {{exitCode}}(으)로 종료됨", "malformedCommand": "잘못된 명령: shell 구문 오류", "manageCommands": "자동 승인된 명령", + "denied": "명령 거부됨", "addToAllowed": "허용 목록에 추가", "removeFromAllowed": "허용 목록에서 제거", "addToDenied": "거부 목록에 추가", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index aa7608b1f3..f9557cb37f 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -228,6 +228,7 @@ "exitStatus": "Afgesloten met status {{exitCode}}", "malformedCommand": "Ongeldig commando: shell syntaxisfout", "manageCommands": "Automatisch goedgekeurde commando's", + "denied": "Commando geweigerd", "addToAllowed": "Toevoegen aan toegestane lijst", "removeFromAllowed": "Verwijderen van toegestane lijst", "addToDenied": "Toevoegen aan geweigerde lijst", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 505d8b766c..fdc598c814 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -233,6 +233,7 @@ "exitStatus": "Zakończono ze statusem {{exitCode}}", "malformedCommand": "Nieprawidlowe polecenie: blad skladni shell", "manageCommands": "Polecenia zatwierdzone automatycznie", + "denied": "Polecenie odrzucone", "addToAllowed": "Dodaj do listy dozwolonych", "removeFromAllowed": "Usuń z listy dozwolonych", "addToDenied": "Dodaj do listy odrzuconych", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 45e7b227ce..9cb658529d 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -233,6 +233,7 @@ "exitStatus": "Saiu com o status {{exitCode}}", "malformedCommand": "Comando malformado: erro de sintaxe do shell", "manageCommands": "Comandos aprovados automaticamente", + "denied": "Comando negado", "addToAllowed": "Adicionar à lista de permitidos", "removeFromAllowed": "Remover da lista de permitidos", "addToDenied": "Adicionar à lista de negados", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 5b8ae0da19..dd118ace19 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -228,6 +228,7 @@ "exitStatus": "Завершено со статусом {{exitCode}}", "malformedCommand": "Некорректная команда: синтаксическая ошибка shell", "manageCommands": "Управление разрешениями команд", + "denied": "Команда запрещена", "commandManagementDescription": "Управляйте разрешениями команд: Нажмите ✓, чтобы разрешить автоматическое выполнение, ✗, чтобы запретить выполнение. Шаблоны можно включать/выключать или удалять из списков. Просмотреть все настройки", "addToAllowed": "Добавить в список разрешенных", "removeFromAllowed": "Удалить из списка разрешенных", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index a5c0bac756..15a199a702 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -233,6 +233,7 @@ "exitStatus": "{{exitCode}} durumuyla çıkıldı", "malformedCommand": "Hatalı komut: shell sozdizimi hatasi", "manageCommands": "Komut İzinlerini Yönet", + "denied": "Komut reddedildi", "commandManagementDescription": "Komut izinlerini yönetin: Otomatik yürütmeye izin vermek için ✓'e, yürütmeyi reddetmek için ✗'e tıklayın. Desenler açılıp kapatılabilir veya listelerden kaldırılabilir. Tüm ayarları görüntüle", "addToAllowed": "İzin verilenler listesine ekle", "removeFromAllowed": "İzin verilenler listesinden kaldır", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index b5da186e43..89e1b08fa9 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -233,6 +233,7 @@ "exitStatus": "Đã thoát với trạng thái {{exitCode}}", "malformedCommand": "Lệnh không hợp lệ: lỗi cú pháp shell", "manageCommands": "Quản lý quyền lệnh", + "denied": "Lệnh bị từ chối", "commandManagementDescription": "Quản lý quyền lệnh: Nhấp vào ✓ để cho phép tự động thực thi, ✗ để từ chối thực thi. Các mẫu có thể được bật/tắt hoặc xóa khỏi danh sách. Xem tất cả cài đặt", "addToAllowed": "Thêm vào danh sách cho phép", "removeFromAllowed": "Xóa khỏi danh sách cho phép", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 63b8ac69fd..3b9e5047e8 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -233,6 +233,7 @@ "exitStatus": "已退出,状态码 {{exitCode}}", "malformedCommand": "命令格式错误:shell 语法错误", "manageCommands": "管理命令权限", + "denied": "命令已拒绝", "commandManagementDescription": "管理命令权限:点击 ✓ 允许自动执行,点击 ✗ 拒绝执行。可以打开/关闭模式或从列表中删除。查看所有设置", "addToAllowed": "添加到允许列表", "removeFromAllowed": "从允许列表中删除", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index dbea150f29..2e85c13b7b 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -279,6 +279,7 @@ "exitStatus": "已結束,狀態碼 {{exitCode}}", "malformedCommand": "命令格式錯誤:shell 語法錯誤", "manageCommands": "自動核准的命令", + "denied": "命令已拒絕", "addToAllowed": "新增至允許清單", "removeFromAllowed": "從允許清單中移除", "addToDenied": "新增至拒絕清單", From 6c35c4e2cb9c1c1f50e69173f0d252e5045e965f Mon Sep 17 00:00:00 2001 From: Naved Date: Tue, 28 Jul 2026 22:03:32 -0700 Subject: [PATCH 03/12] implement dcg --- packages/types/src/global-settings.ts | 1 + packages/types/src/vscode-extension-host.ts | 1 + src/core/auto-approval/__tests__/dcg.spec.ts | 40 ++++ src/core/auto-approval/index.ts | 8 +- src/core/tools/ExecuteCommandTool.ts | 34 ++- src/core/webview/ClineProvider.ts | 3 + .../webview/__tests__/ClineProvider.spec.ts | 9 + src/core/webview/webviewMessageHandler.ts | 12 + .../__tests__/manager.spec.ts | 28 +++ .../destructive-command-guard/constants.ts | 53 +++++ .../destructive-command-guard/index.ts | 3 + .../destructive-command-guard/manager.ts | 212 ++++++++++++++++++ .../destructive-command-guard/runner.ts | 84 +++++++ .../settings/AutoApproveSettings.tsx | 124 ++++++---- .../src/components/settings/SettingsView.tsx | 3 + .../__tests__/AutoApproveSettings.spec.tsx | 17 ++ webview-ui/src/i18n/locales/ca/settings.json | 4 + webview-ui/src/i18n/locales/de/settings.json | 4 + webview-ui/src/i18n/locales/en/settings.json | 4 + webview-ui/src/i18n/locales/es/settings.json | 4 + webview-ui/src/i18n/locales/fr/settings.json | 4 + webview-ui/src/i18n/locales/hi/settings.json | 4 + webview-ui/src/i18n/locales/id/settings.json | 4 + webview-ui/src/i18n/locales/it/settings.json | 4 + webview-ui/src/i18n/locales/ja/settings.json | 4 + webview-ui/src/i18n/locales/ko/settings.json | 4 + webview-ui/src/i18n/locales/nl/settings.json | 4 + webview-ui/src/i18n/locales/pl/settings.json | 4 + .../src/i18n/locales/pt-BR/settings.json | 4 + webview-ui/src/i18n/locales/ru/settings.json | 4 + webview-ui/src/i18n/locales/tr/settings.json | 4 + webview-ui/src/i18n/locales/vi/settings.json | 4 + .../src/i18n/locales/zh-CN/settings.json | 4 + .../src/i18n/locales/zh-TW/settings.json | 4 + 34 files changed, 651 insertions(+), 53 deletions(-) create mode 100644 src/core/auto-approval/__tests__/dcg.spec.ts create mode 100644 src/services/destructive-command-guard/__tests__/manager.spec.ts create mode 100644 src/services/destructive-command-guard/constants.ts create mode 100644 src/services/destructive-command-guard/index.ts create mode 100644 src/services/destructive-command-guard/manager.ts create mode 100644 src/services/destructive-command-guard/runner.ts diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index fe75a1c9e1..11eb59a2d4 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -137,6 +137,7 @@ export const globalSettingsSchema = z.object({ alwaysAllowSubtasks: z.boolean().optional(), alwaysAllowExecute: z.boolean().optional(), alwaysAllowCommandsExceptDenied: z.boolean().optional(), + destructiveCommandGuardEnabled: z.boolean().optional(), alwaysAllowFollowupQuestions: z.boolean().optional(), followupAutoApproveTimeoutMs: z.number().optional(), allowedCommands: z.array(z.string()).optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 2338c664a3..687b64bcc5 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -275,6 +275,7 @@ export type ExtensionState = Pick< | "alwaysAllowFollowupQuestions" | "alwaysAllowExecute" | "alwaysAllowCommandsExceptDenied" + | "destructiveCommandGuardEnabled" | "followupAutoApproveTimeoutMs" | "allowedCommands" | "deniedCommands" diff --git a/src/core/auto-approval/__tests__/dcg.spec.ts b/src/core/auto-approval/__tests__/dcg.spec.ts new file mode 100644 index 0000000000..9340332a08 --- /dev/null +++ b/src/core/auto-approval/__tests__/dcg.spec.ts @@ -0,0 +1,40 @@ +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"], + alwaysAllowCommandsExceptDenied: false, + destructiveCommandGuardEnabled: true, + mcpServers: [], + } + + it("ignores Zoo's deny list while DCG is enabled", async () => { + expect(await checkAutoApproval({ state: baseState, 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("retains ordinary allowlist auto-approval for DCG-allowed commands", async () => { + expect(await checkAutoApproval({ state: baseState, ask: "command", text: "echo safe" })).toEqual({ + decision: "approve", + }) + }) +}) diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index 36f4feed64..34ab333cb6 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -34,6 +34,7 @@ export type AutoApprovalStateOptions = | "allowedCommands" // For `alwaysAllowExecute`. | "deniedCommands" | "alwaysAllowCommandsExceptDenied" + | "destructiveCommandGuardEnabled" export type CheckAutoApprovalResult = | { decision: "approve" } @@ -116,12 +117,17 @@ export async function checkAutoApproval({ if (!text) { return { decision: "ask" } } + if (isProtected) { + return { decision: "ask" } + } + // DCG only changes commands that its policy blocks. Commands that pass + // continue through Zoo's existing allowlist/permission flow. if (state.alwaysAllowExecute === true) { const decision = getCommandDecision( text, state.allowedCommands || [], - state.deniedCommands || [], + state.destructiveCommandGuardEnabled === true ? [] : state.deniedCommands || [], state.alwaysAllowCommandsExceptDenied === true, ) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 6e8a9becb4..1c87edcf1b 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -115,16 +115,42 @@ 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 { getDcgBinaryPath, runDcg } = await import("../../services/destructive-command-guard") + const binaryPath = provider ? getDcgBinaryPath(provider.context.globalStorageUri.fsPath) : undefined + if (!binaryPath) { + throw new Error("Destructive Command Guard is enabled but is not available for this platform") + } + 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( + "text", + `Destructive Command Guard blocked this command${dcgResult.reason ? `: ${dcgResult.reason}` : "."}${dcgResult.ruleId ? ` (Rule: ${dcgResult.ruleId})` : ""}`, + ) + } + } + + // A DCG block is intentionally presented as Zoo's normal command approval + // prompt. Passing isProtected bypasses command auto-approval so the user + // must 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) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index f6236ba2f9..395089126d 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2311,6 +2311,7 @@ export class ClineProvider alwaysAllowWriteProtected, alwaysAllowExecute, alwaysAllowCommandsExceptDenied, + destructiveCommandGuardEnabled, allowedCommands, deniedCommands, alwaysAllowMcp, @@ -2461,6 +2462,7 @@ export class ClineProvider alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false, alwaysAllowExecute: alwaysAllowExecute ?? false, alwaysAllowCommandsExceptDenied: alwaysAllowCommandsExceptDenied ?? false, + destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? false, alwaysAllowMcp: alwaysAllowMcp ?? false, alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: alwaysAllowSubtasks ?? false, @@ -2694,6 +2696,7 @@ export class ClineProvider alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, alwaysAllowCommandsExceptDenied: stateValues.alwaysAllowCommandsExceptDenied ?? false, + destructiveCommandGuardEnabled: stateValues.destructiveCommandGuardEnabled ?? false, alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 762d36b7a4..866b3354f7 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1020,6 +1020,15 @@ describe("ClineProvider", () => { expect(state.alwaysAllowCommandsExceptDenied).toBe(true) }) + test("getStateToPostToWebview returns the saved destructive command guard setting", async () => { + await provider.resolveWebviewView(mockWebviewView) + await provider.contextProxy.setValue("destructiveCommandGuardEnabled", true) + + const state = await provider.getStateToPostToWebview() + + expect(state.destructiveCommandGuardEnabled).toBe(true) + }) + test("language is set to VSCode language", async () => { // Mock VSCode language as Spanish ;(vscode.env as any).language = "pt-BR" diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 7009343573..a11c3e9a14 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -681,6 +681,18 @@ export const webviewMessageHandler = async ( case "updateSettings": if (message.updatedSettings) { + if (message.updatedSettings.destructiveCommandGuardEnabled === true) { + try { + const { ensureDcgInstalled } = await import("../../services/destructive-command-guard") + await ensureDcgInstalled(provider.context.globalStorageUri.fsPath) + } catch (error) { + message.updatedSettings.destructiveCommandGuardEnabled = false + vscode.window.showErrorMessage( + `Unable to enable Destructive Command Guard: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + for (const [key, value] of Object.entries(message.updatedSettings)) { let newValue = value diff --git a/src/services/destructive-command-guard/__tests__/manager.spec.ts b/src/services/destructive-command-guard/__tests__/manager.spec.ts new file mode 100644 index 0000000000..226695aaaf --- /dev/null +++ b/src/services/destructive-command-guard/__tests__/manager.spec.ts @@ -0,0 +1,28 @@ +import { DCG_ARCHIVES } from "../constants" +import { getDcgArchiveInfo, getDcgBinaryPath, isDcgSupportedPlatform } from "../manager" + +describe("Destructive Command Guard manager", () => { + it("maps all supported platform and architecture combinations", () => { + expect(Object.keys(DCG_ARCHIVES).sort()).toEqual([ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "win32-arm64", + "win32-x64", + ]) + expect(getDcgArchiveInfo("darwin", "arm64")?.archive).toBe("dcg-aarch64-apple-darwin.tar.xz") + expect(getDcgArchiveInfo("win32", "x64")?.binary).toBe("dcg.exe") + }) + + it("rejects unsupported platforms", () => { + expect(isDcgSupportedPlatform("freebsd", "x64")).toBe(false) + expect(getDcgBinaryPath("/storage", "freebsd", "x64")).toBeUndefined() + }) + + it("returns a versioned managed binary path", () => { + expect(getDcgBinaryPath("/storage", "linux", "x64")).toMatch( + /[/\\]destructive-command-guard[/\\]v0\.7\.7[/\\]dcg$/, + ) + }) +}) diff --git a/src/services/destructive-command-guard/constants.ts b/src/services/destructive-command-guard/constants.ts new file mode 100644 index 0000000000..6900732fda --- /dev/null +++ b/src/services/destructive-command-guard/constants.ts @@ -0,0 +1,53 @@ +export const DCG_VERSION = "v0.7.7" + +export type DcgArchiveInfo = { + archive: string + binary: "dcg" | "dcg.exe" + sha256: string +} + +export const DCG_ARCHIVES: Readonly> = { + "darwin-arm64": { + archive: "dcg-aarch64-apple-darwin.tar.xz", + binary: "dcg", + sha256: "a63cf82bd3584055112d5ec7a4ab3d7e0619a9f806a53930c27aa0e6297484de", + }, + "darwin-x64": { + archive: "dcg-x86_64-apple-darwin.tar.xz", + binary: "dcg", + sha256: "15b42fbbbeab47123899e6328d90cd593e14999f3d275f71294815ad8ed9479c", + }, + "linux-arm64": { + archive: "dcg-aarch64-unknown-linux-gnu.tar.xz", + binary: "dcg", + sha256: "abb0d94f23ab50f9edc16f8ca6939ff8eec23e1831d3ad7a28d9f03252c3306d", + }, + "linux-x64": { + archive: "dcg-x86_64-unknown-linux-musl.tar.xz", + binary: "dcg", + sha256: "472b130a9b235edc57e6cb7566641da5fef905e9dbefd3a46f9ad1e33205fa04", + }, + "win32-arm64": { + archive: "dcg-aarch64-pc-windows-msvc.zip", + binary: "dcg.exe", + sha256: "93d4c71860086db00bea3aa957051cef38eac572422fa40b2a045c8cd578c3b5", + }, + "win32-x64": { + archive: "dcg-x86_64-pc-windows-msvc.zip", + binary: "dcg.exe", + sha256: "435127410eabc53e772be4f5c668a875b45fbaf806654b577c2d975bd0e38964", + }, +} + +export const DCG_DOWNLOAD_BASE_URL = `https://github.com/Dicklesworthstone/destructive_command_guard/releases/download/${DCG_VERSION}` + +export const DCG_MAX_ARCHIVE_BYTES = 16 * 1024 * 1024 +export const DCG_DOWNLOAD_TIMEOUT_MS = 60_000 +export const DCG_RUN_TIMEOUT_MS = 3_000 +export const DCG_MAX_OUTPUT_BYTES = 256 * 1024 + +export const DCG_TRUSTED_DOWNLOAD_DOMAINS = [ + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +] as const diff --git a/src/services/destructive-command-guard/index.ts b/src/services/destructive-command-guard/index.ts new file mode 100644 index 0000000000..f58e907ac6 --- /dev/null +++ b/src/services/destructive-command-guard/index.ts @@ -0,0 +1,3 @@ +export * from "./constants" +export * from "./manager" +export * from "./runner" diff --git a/src/services/destructive-command-guard/manager.ts b/src/services/destructive-command-guard/manager.ts new file mode 100644 index 0000000000..24ef392cc4 --- /dev/null +++ b/src/services/destructive-command-guard/manager.ts @@ -0,0 +1,212 @@ +import { createHash } from "crypto" +import { spawn } from "child_process" +import { createReadStream, createWriteStream } from "fs" +import * as fs from "fs/promises" +import * as https from "https" +import * as path from "path" + +import { + DCG_ARCHIVES, + DCG_DOWNLOAD_BASE_URL, + DCG_DOWNLOAD_TIMEOUT_MS, + DCG_MAX_ARCHIVE_BYTES, + DCG_TRUSTED_DOWNLOAD_DOMAINS, + DCG_VERSION, + type DcgArchiveInfo, +} from "./constants" + +const installationPromises = new Map>() + +export function getDcgArchiveInfo(platform = process.platform, arch = process.arch): DcgArchiveInfo | undefined { + return DCG_ARCHIVES[`${platform}-${arch}`] +} + +export function isDcgSupportedPlatform(platform = process.platform, arch = process.arch): boolean { + return getDcgArchiveInfo(platform, arch) !== undefined +} + +export function getDcgBinaryPath( + storageDir: string, + platform = process.platform, + arch = process.arch, +): string | undefined { + const info = getDcgArchiveInfo(platform, arch) + return info ? path.join(storageDir, "destructive-command-guard", DCG_VERSION, info.binary) : undefined +} + +function isTrustedDownloadUrl(url: string): boolean { + try { + const parsed = new URL(url) + return ( + parsed.protocol === "https:" && + DCG_TRUSTED_DOWNLOAD_DOMAINS.some( + (domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`), + ) + ) + } catch { + return false + } +} + +function downloadFile(url: string, destination: string, redirectsRemaining = 5): Promise { + return new Promise((resolve, reject) => { + if (!isTrustedDownloadUrl(url)) { + reject(new Error("DCG download redirected to an untrusted host")) + return + } + + const request = https.get(url, (response) => { + const status = response.statusCode ?? 0 + if ([301, 302, 303, 307, 308].includes(status)) { + response.resume() + if (redirectsRemaining <= 0 || !response.headers.location) { + reject(new Error("Too many DCG download redirects")) + return + } + const nextUrl = new URL(response.headers.location, url).toString() + downloadFile(nextUrl, destination, redirectsRemaining - 1).then(resolve, reject) + return + } + + if (status !== 200) { + response.resume() + reject(new Error(`DCG download failed with HTTP ${status}`)) + return + } + + const declaredSize = Number(response.headers["content-length"] ?? 0) + if (declaredSize > DCG_MAX_ARCHIVE_BYTES) { + response.resume() + reject(new Error("DCG archive exceeds the download size limit")) + return + } + + let received = 0 + const output = createWriteStream(destination, { flags: "wx", mode: 0o600 }) + response.on("data", (chunk: Buffer) => { + received += chunk.length + if (received > DCG_MAX_ARCHIVE_BYTES) { + request.destroy(new Error("DCG archive exceeds the download size limit")) + } + }) + response.pipe(output) + output.on("finish", () => output.close(() => resolve())) + output.on("error", reject) + }) + + request.setTimeout(DCG_DOWNLOAD_TIMEOUT_MS, () => request.destroy(new Error("DCG download timed out"))) + request.on("error", reject) + }) +} + +async function verifyChecksum(filePath: string, expected: string): Promise { + const hash = createHash("sha256") + await new Promise((resolve, reject) => { + const input = createReadStream(filePath) + input.on("data", (chunk) => hash.update(chunk)) + input.on("end", resolve) + input.on("error", reject) + }) + if (hash.digest("hex") !== expected) { + throw new Error("DCG archive checksum verification failed") + } +} + +function runProcess( + executable: string, + args: string[], + timeoutMs = 30_000, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "" + let stderr = "" + const timer = setTimeout(() => { + child.kill("SIGKILL") + reject(new Error(`${path.basename(executable)} timed out`)) + }, timeoutMs) + child.stdout?.on("data", (chunk: Buffer) => (stdout += chunk.toString())) + child.stderr?.on("data", (chunk: Buffer) => (stderr += chunk.toString())) + child.on("error", (error) => { + clearTimeout(timer) + reject(error) + }) + child.on("close", (code) => { + clearTimeout(timer) + if (code === 0) { + resolve({ stdout, stderr }) + } else { + reject(new Error(stderr.trim() || `Process exited with code ${code}`)) + } + }) + }) +} + +async function extractSingleBinary(archivePath: string, stagingDir: string, info: DcgArchiveInfo): Promise { + const listingArgs = info.archive.endsWith(".zip") ? ["-tf", archivePath] : ["-tJf", archivePath] + const listing = await runProcess("tar", listingArgs) + const entries = listing.stdout + .split(/\r?\n/) + .map((entry) => entry.trim().replace(/^\.\//, "")) + .filter(Boolean) + if (entries.length !== 1 || entries[0] !== info.binary) { + throw new Error("DCG archive has an unexpected layout") + } + + const extractArgs = info.archive.endsWith(".zip") + ? ["-xf", archivePath, "-C", stagingDir, info.binary] + : ["-xJf", archivePath, "-C", stagingDir, info.binary] + await runProcess("tar", extractArgs) +} + +async function installDcg(storageDir: string): Promise { + const info = getDcgArchiveInfo() + if (!info) { + throw new Error(`Destructive Command Guard is not available for ${process.platform}-${process.arch}`) + } + + const installRoot = path.join(storageDir, "destructive-command-guard") + const finalDir = path.join(installRoot, DCG_VERSION) + const binaryPath = path.join(finalDir, info.binary) + try { + await fs.access(binaryPath) + return binaryPath + } catch { + // First install, or the managed executable was removed. + } + + await fs.mkdir(installRoot, { recursive: true }) + const stagingDir = path.join(installRoot, `${DCG_VERSION}.staging-${process.pid}-${Date.now()}`) + const archivePath = path.join(installRoot, `${info.archive}.${process.pid}.${Date.now()}.download`) + await fs.mkdir(stagingDir, { recursive: true }) + + try { + await downloadFile(`${DCG_DOWNLOAD_BASE_URL}/${info.archive}`, archivePath) + await verifyChecksum(archivePath, info.sha256) + await extractSingleBinary(archivePath, stagingDir, info) + const stagedBinary = path.join(stagingDir, info.binary) + if (process.platform !== "win32") { + await fs.chmod(stagedBinary, 0o755) + } + const version = await runProcess(stagedBinary, ["--version"], 10_000) + if (!`${version.stdout}\n${version.stderr}`.includes(DCG_VERSION.replace(/^v/, ""))) { + throw new Error("Downloaded DCG executable reported an unexpected version") + } + await fs.rm(finalDir, { recursive: true, force: true }) + await fs.rename(stagingDir, finalDir) + return binaryPath + } finally { + await fs.rm(archivePath, { force: true }).catch(() => {}) + await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => {}) + } +} + +export function ensureDcgInstalled(storageDir: string): Promise { + const existing = installationPromises.get(storageDir) + if (existing) { + return existing + } + const promise = installDcg(storageDir).finally(() => installationPromises.delete(storageDir)) + installationPromises.set(storageDir, promise) + return promise +} diff --git a/src/services/destructive-command-guard/runner.ts b/src/services/destructive-command-guard/runner.ts new file mode 100644 index 0000000000..22c0ac58e2 --- /dev/null +++ b/src/services/destructive-command-guard/runner.ts @@ -0,0 +1,84 @@ +import { spawn } from "child_process" + +import { DCG_MAX_OUTPUT_BYTES, DCG_RUN_TIMEOUT_MS } from "./constants" + +export type DcgDecision = { decision: "allow" } | { decision: "deny"; reason?: string; ruleId?: string } + +type DcgJsonOutput = { + schema_version?: number | string + decision?: string + reason?: string + rule_id?: string + pattern_name?: string + pack_id?: string +} + +export function runDcg(binaryPath: string, command: string, cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(binaryPath, ["test", "--format", "json", "--no-color", command], { + cwd, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, NO_COLOR: "1" }, + }) + let stdout: Buffer = Buffer.alloc(0) + let stderr: Buffer = Buffer.alloc(0) + let settled = false + const fail = (error: Error) => { + if (settled) return + settled = true + clearTimeout(timer) + child.kill("SIGKILL") + reject(error) + } + const append = (current: Buffer, chunk: Buffer): Buffer => { + if (current.length + chunk.length > DCG_MAX_OUTPUT_BYTES) { + fail(new Error("DCG produced too much output")) + return current + } + return Buffer.concat([current, chunk]) + } + const timer = setTimeout(() => fail(new Error("DCG evaluation timed out")), DCG_RUN_TIMEOUT_MS) + child.stdout?.on("data", (chunk: Buffer) => (stdout = append(stdout, chunk))) + child.stderr?.on("data", (chunk: Buffer) => (stderr = append(stderr, chunk))) + child.on("error", (error) => fail(new Error(`Unable to start DCG: ${error.message}`))) + child.on("close", (code, signal) => { + if (settled) return + settled = true + clearTimeout(timer) + if (signal || (code !== 0 && code !== 1)) { + reject(new Error(`DCG evaluation failed${stderr.length ? `: ${stderr.toString().trim()}` : ""}`)) + return + } + + let payload: DcgJsonOutput + try { + payload = JSON.parse(stdout.toString("utf8")) as DcgJsonOutput + } catch { + reject(new Error("DCG returned invalid JSON")) + return + } + + const schemaVersion = Number(payload.schema_version) + if (![1, 2].includes(schemaVersion)) { + reject(new Error("DCG returned an unsupported response schema")) + return + } + if (payload.decision === "allow" && code === 0) { + resolve({ decision: "allow" }) + } else if (payload.decision === "deny" && code === 1) { + resolve({ + decision: "deny", + reason: payload.reason, + ruleId: + payload.rule_id ?? + (payload.pack_id && payload.pattern_name + ? `${payload.pack_id}:${payload.pattern_name}` + : undefined), + }) + } else { + reject(new Error("DCG decision did not match its exit status")) + } + }) + }) +} diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 34d4aea775..28588a4235 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -29,6 +29,7 @@ type AutoApproveSettingsProps = HTMLAttributes & { alwaysAllowSubtasks?: boolean alwaysAllowExecute?: boolean alwaysAllowCommandsExceptDenied?: boolean + destructiveCommandGuardEnabled?: boolean alwaysAllowFollowupQuestions?: boolean followupAutoApproveTimeoutMs?: number allowedCommands?: string[] @@ -46,6 +47,7 @@ type AutoApproveSettingsProps = HTMLAttributes & { | "alwaysAllowSubtasks" | "alwaysAllowExecute" | "alwaysAllowCommandsExceptDenied" + | "destructiveCommandGuardEnabled" | "alwaysAllowFollowupQuestions" | "followupAutoApproveTimeoutMs" | "allowedCommands" @@ -66,6 +68,7 @@ export const AutoApproveSettings = ({ alwaysAllowSubtasks, alwaysAllowExecute, alwaysAllowCommandsExceptDenied, + destructiveCommandGuardEnabled, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs = 60000, allowedCommands, @@ -222,6 +225,7 @@ export const AutoApproveSettings = ({ {t("settings:autoApprove.write.outsideWorkspace.description")}
+ + + + setCachedStateField("destructiveCommandGuardEnabled", e.target.checked) + } + data-testid="destructive-command-guard-checkbox"> + + {t("settings:autoApprove.execute.destructiveCommandGuard.label")} + + +
+ {t("settings:autoApprove.execute.destructiveCommandGuard.description")} +
+
+ {!alwaysAllowCommandsExceptDenied && ( <> - -
- {t("settings:autoApprove.execute.deniedCommandsDescription")} -
-
- -
- setDeniedCommandInput(e.target.value)} - onKeyDown={(e: any) => { - if (e.key === "Enter") { - e.preventDefault() - handleAddDeniedCommand() - } - }} - placeholder={t("settings:autoApprove.execute.deniedCommandPlaceholder")} - className="grow" - data-testid="denied-command-input" - /> - -
+
+ + +
+ {t("settings:autoApprove.execute.deniedCommandsDescription")} +
+
-
- {(deniedCommands ?? []).map((cmd, index) => ( +
+ setDeniedCommandInput(e.target.value)} + onKeyDown={(e: any) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddDeniedCommand() + } + }} + placeholder={t("settings:autoApprove.execute.deniedCommandPlaceholder")} + className="grow" + data-testid="denied-command-input" + /> - ))} +
+ +
+ {(deniedCommands ?? []).map((cmd, index) => ( + + ))} +
)} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index ca24e2b76b..d93cb6d279 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -163,6 +163,7 @@ const SettingsView = forwardRef(({ onDone, t language, alwaysAllowExecute, alwaysAllowCommandsExceptDenied, + destructiveCommandGuardEnabled, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, @@ -389,6 +390,7 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? undefined, alwaysAllowExecute: alwaysAllowExecute ?? undefined, alwaysAllowCommandsExceptDenied: alwaysAllowCommandsExceptDenied ?? false, + destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? false, alwaysAllowMcp, alwaysAllowModeSwitch, allowedCommands: allowedCommands ?? [], @@ -817,6 +819,7 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowSubtasks={alwaysAllowSubtasks} alwaysAllowExecute={alwaysAllowExecute} alwaysAllowCommandsExceptDenied={alwaysAllowCommandsExceptDenied} + destructiveCommandGuardEnabled={destructiveCommandGuardEnabled} alwaysAllowFollowupQuestions={alwaysAllowFollowupQuestions} followupAutoApproveTimeoutMs={followupAutoApproveTimeoutMs} allowedCommands={allowedCommands} diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx index 56c1e1b279..8dc409a7d1 100644 --- a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx @@ -113,4 +113,21 @@ describe("AutoApproveSettings - Save/Discard contract", () => { expect(screen.queryByTestId("allowed-commands-heading")).not.toBeInTheDocument() expect(screen.getByTestId("denied-commands-heading")).toBeInTheDocument() }) + + it("buffers the destructive command guard setting", () => { + const { setCachedStateField } = renderSettings() + + fireEvent.click(screen.getByTestId("destructive-command-guard-checkbox")) + + expect(setCachedStateField).toHaveBeenCalledWith("destructiveCommandGuardEnabled", true) + expectNoImmediateUpdateSettings() + }) + + it("disables the denied commands editor while destructive command guard is enabled", () => { + renderSettings({ destructiveCommandGuardEnabled: true, deniedCommands: ["rm -rf"] }) + + expect(screen.getByTestId("denied-command-input")).toBeDisabled() + expect(screen.getByTestId("add-denied-command-button")).toBeDisabled() + expect(screen.getByTestId("remove-denied-command-0")).toBeDisabled() + }) }) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index b959721985..84133a53a5 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -332,6 +332,10 @@ "label": "Aprova automàticament totes les comandes excepte les denegades", "description": "Aprova automàticament qualsevol comanda que no coincideixi amb el prefix d'una comanda denegada. La llista de comandes permeses s'ignora mentre aquesta opció està activada. Les comandes malformades i les substitucions perilloses continuen requerint revisió." }, + "destructiveCommandGuard": { + "label": "Activa la protecció contra ordres destructives", + "description": "Baixa i utilitza Destructive Command Guard (DCG) per a aquesta plataforma. Les ordres bloquejades per DCG requeriran la teva aprovació. La llista d'ordres denegades de Zoo es desactiva mentre aquesta opció està activa. L'executable baixat es conserva si la desactives." + }, "allowedCommands": "Comandes d'auto-execució permeses", "allowedCommandsDescription": "Prefixos de comandes que poden ser executats automàticament quan \"Aprovar sempre operacions d'execució\" està habilitat. Afegeix * per permetre totes les comandes (usar amb precaució).", "deniedCommands": "Comandes denegades", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index e16ff08b4e..d2fdf3e17b 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -332,6 +332,10 @@ "label": "Alle Befehle außer verweigerten automatisch genehmigen", "description": "Genehmigt automatisch jeden Befehl, der mit keinem Präfix eines verweigerten Befehls übereinstimmt. Die Liste erlaubter Befehle wird ignoriert, solange diese Einstellung aktiviert ist. Fehlerhafte Befehle und gefährliche Ersetzungen müssen weiterhin geprüft werden." }, + "destructiveCommandGuard": { + "label": "Schutz vor destruktiven Befehlen aktivieren", + "description": "Lädt Destructive Command Guard (DCG) für diese Plattform herunter und verwendet es. Von DCG blockierte Befehle benötigen deine Zustimmung. Zoos Liste abgelehnter Befehle ist währenddessen deaktiviert. Die heruntergeladene ausführbare Datei bleibt erhalten, wenn du die Option ausschaltest." + }, "allowedCommands": "Erlaubte Auto-Ausführungsbefehle", "allowedCommandsDescription": "Befehlspräfixe, die automatisch ausgeführt werden können, wenn 'Ausführungsoperationen immer genehmigen' aktiviert ist. Fügen Sie * hinzu, um alle Befehle zu erlauben (mit Vorsicht verwenden).", "deniedCommands": "Verweigerte Befehle", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index adf39a08ff..c268d604a1 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -410,6 +410,10 @@ "label": "Auto-approve all commands except denied commands", "description": "Auto-approve any command that does not match a denied command prefix. The allowed commands list is ignored while this setting is enabled. Malformed commands and dangerous substitutions still require review." }, + "destructiveCommandGuard": { + "label": "Enable destructive command guard", + "description": "Download and use Destructive Command Guard (DCG) for this platform. Commands blocked by DCG will require your approval. Zoo's denied commands list is disabled while this is enabled. The downloaded executable is retained if you turn this off." + }, "allowedCommands": "Allowed Auto-Execute Commands", "allowedCommandsDescription": "Command prefixes that can be auto-executed when \"Always approve execute operations\" is enabled. Add * to allow all commands (use with caution).", "deniedCommands": "Denied Commands", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index fe4b35abed..aa37f14cb2 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -332,6 +332,10 @@ "label": "Autoaprobar todos los comandos excepto los denegados", "description": "Aprueba automáticamente cualquier comando que no coincida con el prefijo de un comando denegado. La lista de comandos permitidos se ignora mientras esta opción esté habilitada. Los comandos con formato incorrecto y las sustituciones peligrosas aún requieren revisión." }, + "destructiveCommandGuard": { + "label": "Activar la protección contra comandos destructivos", + "description": "Descarga y usa Destructive Command Guard (DCG) para esta plataforma. Los comandos bloqueados por DCG requerirán tu aprobación. La lista de comandos denegados de Zoo se desactiva mientras esta opción está habilitada. El ejecutable descargado se conserva si la desactivas." + }, "allowedCommands": "Comandos de auto-ejecución permitidos", "allowedCommandsDescription": "Prefijos de comandos que pueden ser ejecutados automáticamente cuando \"Aprobar siempre operaciones de ejecución\" está habilitado. Añade * para permitir todos los comandos (usar con precaución).", "deniedCommands": "Comandos denegados", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 7613be6e0d..ab92226ff1 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -333,6 +333,10 @@ "label": "Approuver automatiquement toutes les commandes sauf celles refusées", "description": "Approuve automatiquement toute commande qui ne correspond pas au préfixe d'une commande refusée. La liste des commandes autorisées est ignorée tant que ce paramètre est activé. Les commandes mal formées et les substitutions dangereuses nécessitent toujours une vérification." }, + "destructiveCommandGuard": { + "label": "Activer la protection contre les commandes destructrices", + "description": "Télécharge et utilise Destructive Command Guard (DCG) pour cette plateforme. Les commandes bloquées par DCG nécessiteront ton approbation. La liste des commandes refusées de Zoo est désactivée tant que cette option est active. L'exécutable téléchargé est conservé si tu la désactives." + }, "allowedCommands": "Commandes auto-exécutables autorisées", "allowedCommandsDescription": "Préfixes de commandes qui peuvent être auto-exécutés lorsque \"Toujours approuver les opérations d'exécution\" est activé. Ajoutez * pour autoriser toutes les commandes (à utiliser avec précaution).", "deniedCommands": "Commandes refusées", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 56a10c50f3..5c354ce6a0 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -332,6 +332,10 @@ "label": "अस्वीकृत कमांड को छोड़कर सभी कमांड स्वतः अनुमोदित करें", "description": "ऐसे किसी भी कमांड को स्वतः अनुमोदित करता है जो किसी अस्वीकृत कमांड प्रीफिक्स से मेल नहीं खाता। यह सेटिंग सक्षम होने पर अनुमत कमांड की सूची अनदेखी की जाती है। गलत संरचना वाले कमांड और खतरनाक प्रतिस्थापन की अब भी समीक्षा आवश्यक है।" }, + "destructiveCommandGuard": { + "label": "विनाशकारी कमांड सुरक्षा सक्षम करें", + "description": "इस प्लेटफ़ॉर्म के लिए Destructive Command Guard (DCG) डाउनलोड करके उपयोग करें। DCG द्वारा रोके गए कमांड चलाने के लिए आपकी मंज़ूरी आवश्यक होगी। इसके सक्षम रहने पर Zoo की अस्वीकृत कमांड सूची बंद रहेगी। इसे बंद करने पर डाउनलोड किया गया executable रखा जाएगा।" + }, "allowedCommands": "अनुमत स्वतः-निष्पादन कमांड", "allowedCommandsDescription": "कमांड प्रीफिक्स जो स्वचालित रूप से निष्पादित किए जा सकते हैं जब \"निष्पादन ऑपरेशन हमेशा अनुमोदित करें\" सक्षम है। सभी कमांड की अनुमति देने के लिए * जोड़ें (सावधानी से उपयोग करें)।", "deniedCommands": "अस्वीकृत कमांड", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index ef4b2751e6..f02b7e2fe8 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -332,6 +332,10 @@ "label": "Setujui otomatis semua perintah kecuali perintah yang ditolak", "description": "Setujui otomatis setiap perintah yang tidak cocok dengan prefiks perintah yang ditolak. Daftar perintah yang diizinkan akan diabaikan selama pengaturan ini aktif. Perintah dengan format yang salah dan substitusi berbahaya tetap memerlukan peninjauan." }, + "destructiveCommandGuard": { + "label": "Aktifkan perlindungan perintah destruktif", + "description": "Unduh dan gunakan Destructive Command Guard (DCG) untuk platform ini. Perintah yang diblokir DCG akan memerlukan persetujuanmu. Daftar perintah yang ditolak Zoo dinonaktifkan selama opsi ini aktif. File executable yang diunduh tetap disimpan jika kamu menonaktifkannya." + }, "allowedCommands": "Perintah Auto-Execute yang Diizinkan", "allowedCommandsDescription": "Prefix perintah yang dapat di-auto-execute ketika \"Selalu setujui operasi eksekusi\" diaktifkan. Tambahkan * untuk mengizinkan semua perintah (gunakan dengan hati-hati).", "deniedCommands": "Perintah yang ditolak", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 1a8e3fc50a..ce9628dbae 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -332,6 +332,10 @@ "label": "Approva automaticamente tutti i comandi tranne quelli negati", "description": "Approva automaticamente qualsiasi comando che non corrisponde al prefisso di un comando negato. L'elenco dei comandi consentiti viene ignorato mentre questa impostazione è attiva. I comandi non validi e le sostituzioni pericolose richiedono comunque una revisione." }, + "destructiveCommandGuard": { + "label": "Abilita la protezione dai comandi distruttivi", + "description": "Scarica e usa Destructive Command Guard (DCG) per questa piattaforma. I comandi bloccati da DCG richiederanno la tua approvazione. L'elenco dei comandi negati di Zoo viene disabilitato mentre questa opzione è attiva. L'eseguibile scaricato viene conservato se la disattivi." + }, "allowedCommands": "Comandi di auto-esecuzione consentiti", "allowedCommandsDescription": "Prefissi di comando che possono essere auto-eseguiti quando \"Approva sempre operazioni di esecuzione\" è abilitato. Aggiungi * per consentire tutti i comandi (usare con cautela).", "deniedCommands": "Comandi negati", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index ac7bb6fa1a..0d4ada0274 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -332,6 +332,10 @@ "label": "拒否されたコマンド以外をすべて自動承認", "description": "拒否されたコマンドのプレフィックスに一致しないコマンドを自動承認します。この設定が有効な間、許可されたコマンドのリストは無視されます。不正な形式のコマンドや危険な置換は、引き続き確認が必要です。" }, + "destructiveCommandGuard": { + "label": "破壊的コマンドガードを有効にする", + "description": "このプラットフォーム用の Destructive Command Guard(DCG)をダウンロードして使用します。DCG がブロックしたコマンドの実行には承認が必要です。有効な間は Zoo の拒否コマンドリストが無効になります。無効にしても、ダウンロードした実行ファイルは保持されます。" + }, "allowedCommands": "許可された自動実行コマンド", "allowedCommandsDescription": "「実行操作を常に承認」が有効な場合に自動実行できるコマンドプレフィックス。すべてのコマンドを許可するには * を追加します(注意して使用してください)。", "deniedCommands": "拒否されたコマンド", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 154265fa59..88bac0cd81 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -332,6 +332,10 @@ "label": "거부된 명령을 제외한 모든 명령 자동 승인", "description": "거부된 명령 접두사와 일치하지 않는 모든 명령을 자동 승인합니다. 이 설정이 활성화된 동안에는 허용된 명령 목록이 무시됩니다. 형식이 잘못된 명령과 위험한 치환은 계속 검토가 필요합니다." }, + "destructiveCommandGuard": { + "label": "파괴적 명령어 보호 활성화", + "description": "이 플랫폼용 Destructive Command Guard(DCG)를 다운로드하여 사용합니다. DCG가 차단한 명령어는 실행 전 승인이 필요합니다. 이 옵션을 사용하는 동안 Zoo의 거부 명령어 목록은 비활성화됩니다. 옵션을 꺼도 다운로드한 실행 파일은 유지됩니다." + }, "allowedCommands": "허용된 자동 실행 명령", "allowedCommandsDescription": "\"실행 작업 항상 승인\"이 활성화되었을 때 자동 실행될 수 있는 명령 접두사. 모든 명령을 허용하려면 * 추가(주의해서 사용)", "deniedCommands": "거부된 명령", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 067a4119ae..e177e6cf8d 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -332,6 +332,10 @@ "label": "Alle commando's behalve geweigerde automatisch goedkeuren", "description": "Keurt elk commando automatisch goed dat niet overeenkomt met het voorvoegsel van een geweigerd commando. De lijst met toegestane commando's wordt genegeerd zolang deze instelling is ingeschakeld. Onjuist gevormde commando's en gevaarlijke vervangingen moeten nog steeds worden beoordeeld." }, + "destructiveCommandGuard": { + "label": "Beveiliging tegen destructieve opdrachten inschakelen", + "description": "Download en gebruik Destructive Command Guard (DCG) voor dit platform. Voor opdrachten die DCG blokkeert, is jouw goedkeuring nodig. De lijst met geweigerde opdrachten van Zoo is uitgeschakeld zolang deze optie actief is. Het gedownloade uitvoerbare bestand blijft bewaard als je de optie uitschakelt." + }, "allowedCommands": "Toegestane automatisch uit te voeren commando's", "allowedCommandsDescription": "Commando-prefixen die automatisch kunnen worden uitgevoerd als 'Altijd goedkeuren voor uitvoeren' is ingeschakeld. Voeg * toe om alle commando's toe te staan (gebruik met voorzichtigheid).", "deniedCommands": "Geweigerde commando's", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 8e60c732ff..1d7bcda5b7 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -332,6 +332,10 @@ "label": "Automatycznie zatwierdzaj wszystkie polecenia poza odrzuconymi", "description": "Automatycznie zatwierdza każde polecenie, które nie pasuje do prefiksu odrzuconego polecenia. Lista dozwolonych poleceń jest ignorowana, gdy to ustawienie jest włączone. Nieprawidłowo sformułowane polecenia i niebezpieczne podstawienia nadal wymagają sprawdzenia." }, + "destructiveCommandGuard": { + "label": "Włącz ochronę przed destrukcyjnymi poleceniami", + "description": "Pobiera i używa Destructive Command Guard (DCG) dla tej platformy. Polecenia zablokowane przez DCG będą wymagały twojej zgody. Lista odrzuconych poleceń Zoo jest wyłączona, gdy ta opcja jest aktywna. Pobrany plik wykonywalny pozostaje na dysku po wyłączeniu opcji." + }, "allowedCommands": "Dozwolone polecenia auto-wykonania", "allowedCommandsDescription": "Prefiksy poleceń, które mogą być automatycznie wykonywane, gdy \"Zawsze zatwierdzaj operacje wykonania\" jest włączone. Dodaj * aby zezwolić na wszystkie polecenia (używaj z ostrożnością).", "deniedCommands": "Odrzucone polecenia", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 2cf3a67871..036a086c8f 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -332,6 +332,10 @@ "label": "Aprovar automaticamente todos os comandos, exceto os negados", "description": "Aprova automaticamente qualquer comando que não corresponda ao prefixo de um comando negado. A lista de comandos permitidos é ignorada enquanto esta configuração estiver ativada. Comandos malformados e substituições perigosas ainda exigem revisão." }, + "destructiveCommandGuard": { + "label": "Ativar a proteção contra comandos destrutivos", + "description": "Baixa e usa o Destructive Command Guard (DCG) para esta plataforma. Comandos bloqueados pelo DCG precisarão da sua aprovação. A lista de comandos negados do Zoo fica desativada enquanto esta opção estiver ativa. O executável baixado é mantido se você desativá-la." + }, "allowedCommands": "Comandos de auto-execução permitidos", "allowedCommandsDescription": "Prefixos de comando que podem ser auto-executados quando \"Aprovar sempre operações de execução\" está ativado. Adicione * para permitir todos os comandos (use com cautela).", "deniedCommands": "Comandos negados", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 887b601cf5..eda893d455 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -332,6 +332,10 @@ "label": "Автоматически одобрять все команды, кроме запрещённых", "description": "Автоматически одобряет любую команду, которая не совпадает с префиксом запрещённой команды. Пока эта настройка включена, список разрешённых команд игнорируется. Некорректные команды и опасные подстановки по-прежнему требуют проверки." }, + "destructiveCommandGuard": { + "label": "Включить защиту от разрушительных команд", + "description": "Скачивает и использует Destructive Command Guard (DCG) для этой платформы. Команды, заблокированные DCG, потребуют твоего подтверждения. Пока эта настройка включена, список запрещённых команд Zoo отключён. Скачанный исполняемый файл сохраняется после отключения настройки." + }, "allowedCommands": "Разрешённые авто-выполняемые команды", "allowedCommandsDescription": "Префиксы команд, которые могут быть автоматически выполнены при включённом параметре \"Всегда одобрять выполнение операций\". Добавьте * для разрешения всех команд (используйте с осторожностью).", "deniedCommands": "Запрещенные команды", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 1b2fbfc294..c069a02191 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -332,6 +332,10 @@ "label": "Reddedilenler dışındaki tüm komutları otomatik onayla", "description": "Reddedilen bir komut önekiyle eşleşmeyen tüm komutları otomatik olarak onaylar. Bu ayar etkinken izin verilen komutlar listesi yok sayılır. Hatalı biçimlendirilmiş komutlar ve tehlikeli değiştirmeler yine de inceleme gerektirir." }, + "destructiveCommandGuard": { + "label": "Yıkıcı komut korumasını etkinleştir", + "description": "Bu platform için Destructive Command Guard'ı (DCG) indirir ve kullanır. DCG tarafından engellenen komutlar onayını gerektirir. Bu seçenek etkinken Zoo'nun reddedilen komutlar listesi devre dışı bırakılır. Seçeneği kapatsan da indirilen çalıştırılabilir dosya korunur." + }, "allowedCommands": "İzin Verilen Otomatik Yürütme Komutları", "allowedCommandsDescription": "\"Yürütme işlemlerini her zaman onayla\" etkinleştirildiğinde otomatik olarak yürütülebilen komut önekleri. Tüm komutlara izin vermek için * ekleyin (dikkatli kullanın).", "deniedCommands": "Reddedilen komutlar", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 1eb87f312e..29b3d9d36f 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -332,6 +332,10 @@ "label": "Tự động phê duyệt mọi lệnh ngoại trừ các lệnh bị từ chối", "description": "Tự động phê duyệt mọi lệnh không khớp với tiền tố của lệnh bị từ chối. Danh sách lệnh được phép sẽ bị bỏ qua khi cài đặt này được bật. Các lệnh sai định dạng và phép thay thế nguy hiểm vẫn cần được xem xét." }, + "destructiveCommandGuard": { + "label": "Bật bảo vệ khỏi lệnh phá hoại", + "description": "Tải xuống và sử dụng Destructive Command Guard (DCG) cho nền tảng này. Các lệnh bị DCG chặn sẽ cần bạn phê duyệt. Danh sách lệnh bị từ chối của Zoo sẽ bị vô hiệu hóa khi tùy chọn này được bật. Tệp thực thi đã tải xuống vẫn được giữ lại nếu bạn tắt tùy chọn." + }, "allowedCommands": "Các lệnh tự động thực thi được phép", "allowedCommandsDescription": "Tiền tố lệnh có thể được tự động thực thi khi \"Luôn phê duyệt các hoạt động thực thi\" được bật. Thêm * để cho phép tất cả các lệnh (sử dụng cẩn thận).", "deniedCommands": "Lệnh bị từ chối", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 0d774e42fb..2267c3ac12 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -332,6 +332,10 @@ "label": "自动批准除拒绝命令之外的所有命令", "description": "自动批准所有不匹配拒绝命令前缀的命令。启用此设置时,将忽略允许命令列表。格式错误的命令和危险的替换仍需审核。" }, + "destructiveCommandGuard": { + "label": "启用破坏性命令防护", + "description": "下载并使用适用于此平台的 Destructive Command Guard(DCG)。被 DCG 拦截的命令需要你批准后才能执行。启用后,Zoo 的拒绝命令列表将被禁用。关闭此选项时,已下载的可执行文件会保留。" + }, "allowedCommands": "命令白名单", "allowedCommandsDescription": "当\"自动批准命令行操作\"启用时可以自动执行的命令前缀。添加 * 以允许所有命令(谨慎使用)。", "deniedCommands": "拒绝的命令", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 6454b55543..2dfccc21b0 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -357,6 +357,10 @@ "label": "自動核准拒絕命令以外的所有命令", "description": "自動核准所有不符合拒絕命令前綴的命令。啟用此設定時,允許的命令清單將被忽略。格式錯誤的命令和危險的替換仍需審查。" }, + "destructiveCommandGuard": { + "label": "啟用破壞性命令防護", + "description": "下載並使用適用於此平台的 Destructive Command Guard(DCG)。被 DCG 阻擋的命令需要你核准後才能執行。啟用後,Zoo 的拒絕命令清單將停用。關閉此選項時,已下載的執行檔會保留。" + }, "allowedCommands": "允許自動執行的命令", "allowedCommandsDescription": "啟用「始終核准執行」時,可自動執行的命令前綴。新增 * 可允許所有命令(請謹慎使用)。", "deniedCommands": "拒絕的命令", From 0322513ba4b33caf62af5ba0fe62a7b37f65d8a1 Mon Sep 17 00:00:00 2001 From: Naved Date: Tue, 28 Jul 2026 22:37:10 -0700 Subject: [PATCH 04/12] remove autoapprove workflow --- packages/types/src/global-settings.ts | 1 - packages/types/src/vscode-extension-host.ts | 1 - .../auto-approval/__tests__/commands.spec.ts | 22 --- src/core/auto-approval/__tests__/dcg.spec.ts | 33 ++++- src/core/auto-approval/commands.ts | 9 +- src/core/auto-approval/index.ts | 17 ++- src/core/tools/ExecuteCommandTool.ts | 6 +- src/core/webview/ClineProvider.ts | 3 - .../webview/__tests__/ClineProvider.spec.ts | 11 +- .../src/components/chat/CommandExecution.tsx | 3 +- .../chat/__tests__/CommandExecution.spec.tsx | 15 -- .../settings/AutoApproveSettings.tsx | 133 +++++++----------- .../src/components/settings/SettingsView.tsx | 3 - .../__tests__/AutoApproveSettings.spec.tsx | 29 ++-- webview-ui/src/i18n/locales/ca/settings.json | 6 +- webview-ui/src/i18n/locales/de/settings.json | 6 +- webview-ui/src/i18n/locales/en/settings.json | 6 +- webview-ui/src/i18n/locales/es/settings.json | 6 +- webview-ui/src/i18n/locales/fr/settings.json | 6 +- webview-ui/src/i18n/locales/hi/settings.json | 6 +- webview-ui/src/i18n/locales/id/settings.json | 6 +- webview-ui/src/i18n/locales/it/settings.json | 6 +- webview-ui/src/i18n/locales/ja/settings.json | 6 +- webview-ui/src/i18n/locales/ko/settings.json | 6 +- webview-ui/src/i18n/locales/nl/settings.json | 6 +- webview-ui/src/i18n/locales/pl/settings.json | 6 +- .../src/i18n/locales/pt-BR/settings.json | 6 +- webview-ui/src/i18n/locales/ru/settings.json | 6 +- webview-ui/src/i18n/locales/tr/settings.json | 6 +- webview-ui/src/i18n/locales/vi/settings.json | 6 +- .../src/i18n/locales/zh-CN/settings.json | 6 +- .../src/i18n/locales/zh-TW/settings.json | 6 +- 32 files changed, 126 insertions(+), 268 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 11eb59a2d4..606dd79458 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -136,7 +136,6 @@ export const globalSettingsSchema = z.object({ alwaysAllowModeSwitch: z.boolean().optional(), alwaysAllowSubtasks: z.boolean().optional(), alwaysAllowExecute: z.boolean().optional(), - alwaysAllowCommandsExceptDenied: z.boolean().optional(), destructiveCommandGuardEnabled: z.boolean().optional(), alwaysAllowFollowupQuestions: z.boolean().optional(), followupAutoApproveTimeoutMs: z.number().optional(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 687b64bcc5..63d5be87a8 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -274,7 +274,6 @@ export type ExtensionState = Pick< | "alwaysAllowSubtasks" | "alwaysAllowFollowupQuestions" | "alwaysAllowExecute" - | "alwaysAllowCommandsExceptDenied" | "destructiveCommandGuardEnabled" | "followupAutoApproveTimeoutMs" | "allowedCommands" diff --git a/src/core/auto-approval/__tests__/commands.spec.ts b/src/core/auto-approval/__tests__/commands.spec.ts index 88c695abf8..fa5762cb56 100644 --- a/src/core/auto-approval/__tests__/commands.spec.ts +++ b/src/core/auto-approval/__tests__/commands.spec.ts @@ -36,28 +36,6 @@ describe("getCommandDecision", () => { const result = getCommandDecision(command, ["*"]) expect(result).toBe("auto_approve") }) - - describe("allow all except denied mode", () => { - it("auto-approves a command that is not in the allowlist or denylist", () => { - expect(getCommandDecision("unknown command", [], ["rm"], true)).toBe("auto_approve") - }) - - it("auto-denies a command matching the denylist even if a longer allowlist entry matches", () => { - expect(getCommandDecision("rm --dry-run file", ["rm --dry-run"], ["rm"], true)).toBe("auto_deny") - }) - - it("auto-denies a command chain when any sub-command matches the denylist", () => { - expect(getCommandDecision("git status && rm file", [], ["rm"], true)).toBe("auto_deny") - }) - - it("preserves dangerous substitution protection", () => { - expect(getCommandDecision('echo "${var@P}"', [], [], true)).toBe("ask_user") - }) - - it("preserves malformed command protection", () => { - expect(getCommandDecision("sh -c 'echo a", [], [], true)).toBe("malformed_command") - }) - }) }) describe("containsDangerousSubstitution — node -e one-liner false positive regression", () => { diff --git a/src/core/auto-approval/__tests__/dcg.spec.ts b/src/core/auto-approval/__tests__/dcg.spec.ts index 9340332a08..ae92af2758 100644 --- a/src/core/auto-approval/__tests__/dcg.spec.ts +++ b/src/core/auto-approval/__tests__/dcg.spec.ts @@ -15,14 +15,13 @@ describe("Destructive Command Guard auto-approval precedence", () => { alwaysAllowFollowupQuestions: false, allowedCommands: ["echo"], deniedCommands: ["rm"], - alwaysAllowCommandsExceptDenied: false, destructiveCommandGuardEnabled: true, mcpServers: [], } - it("ignores Zoo's deny list while DCG is enabled", async () => { + 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: "ask", + decision: "approve", }) }) @@ -32,9 +31,33 @@ describe("Destructive Command Guard auto-approval precedence", () => { ).toEqual({ decision: "ask" }) }) - it("retains ordinary allowlist auto-approval for DCG-allowed commands", async () => { - expect(await checkAutoApproval({ state: baseState, ask: "command", text: "echo safe" })).toEqual({ + 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("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", + }) + }) }) diff --git a/src/core/auto-approval/commands.ts b/src/core/auto-approval/commands.ts index b9bc5719f5..82b937e66f 100644 --- a/src/core/auto-approval/commands.ts +++ b/src/core/auto-approval/commands.ts @@ -217,8 +217,7 @@ export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user" | "malfo * **Decision Logic:** * 1. **Dangerous Substitution Protection**: Commands with dangerous parameter expansions are never auto-approved * 2. **Command Parsing**: Split command chains (&&, ||, ;, |, &) into individual commands - * 3. **Individual Validation**: For each sub-command, either apply the longest prefix match rule or, when - * `allowAllExceptDenied` is enabled, approve it unless it matches the denylist + * 3. **Individual Validation**: For each sub-command, apply longest prefix match rule * 4. **Aggregation**: Combine decisions using "any denial blocks all" principle * * **Return Values:** @@ -253,14 +252,12 @@ export type CommandDecision = "auto_approve" | "auto_deny" | "ask_user" | "malfo * @param command - The full command string to validate * @param allowedCommands - List of allowed command prefixes * @param deniedCommands - Optional list of denied command prefixes - * @param allowAllExceptDenied - Auto-approve commands without a denylist match, ignoring the allowlist * @returns Decision indicating whether to approve, deny, or ask user */ export function getCommandDecision( command: string, allowedCommands: string[], deniedCommands?: string[], - allowAllExceptDenied = false, ): CommandDecision { if (!command?.trim()) { return "auto_approve" @@ -285,10 +282,6 @@ export function getCommandDecision( // Remove simple PowerShell-like redirections (e.g. 2>&1) before checking const cmdWithoutRedirection = cmd.replace(/\d*>&\d*/, "").trim() - if (allowAllExceptDenied) { - return findLongestPrefixMatch(cmdWithoutRedirection, deniedCommands || []) ? "auto_deny" : "auto_approve" - } - return getSingleCommandDecision(cmdWithoutRedirection, allowedCommands, deniedCommands) }) diff --git a/src/core/auto-approval/index.ts b/src/core/auto-approval/index.ts index 34ab333cb6..09e665f141 100644 --- a/src/core/auto-approval/index.ts +++ b/src/core/auto-approval/index.ts @@ -33,7 +33,6 @@ export type AutoApprovalStateOptions = | "mcpServers" // For `alwaysAllowMcp`. | "allowedCommands" // For `alwaysAllowExecute`. | "deniedCommands" - | "alwaysAllowCommandsExceptDenied" | "destructiveCommandGuardEnabled" export type CheckAutoApprovalResult = @@ -121,15 +120,15 @@ export async function checkAutoApproval({ return { decision: "ask" } } - // DCG only changes commands that its policy blocks. Commands that pass - // continue through Zoo's existing allowlist/permission flow. if (state.alwaysAllowExecute === true) { - const decision = getCommandDecision( - text, - state.allowedCommands || [], - state.destructiveCommandGuardEnabled === true ? [] : state.deniedCommands || [], - state.alwaysAllowCommandsExceptDenied === 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") { return { decision: "approve" } diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 1c87edcf1b..d52605f637 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -139,9 +139,9 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { } } - // A DCG block is intentionally presented as Zoo's normal command approval - // prompt. Passing isProtected bypasses command auto-approval so the user - // must explicitly choose whether to execute it. + // 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) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 395089126d..4e8c530ccc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2310,7 +2310,6 @@ export class ClineProvider alwaysAllowWriteOutsideWorkspace, alwaysAllowWriteProtected, alwaysAllowExecute, - alwaysAllowCommandsExceptDenied, destructiveCommandGuardEnabled, allowedCommands, deniedCommands, @@ -2461,7 +2460,6 @@ export class ClineProvider alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false, alwaysAllowExecute: alwaysAllowExecute ?? false, - alwaysAllowCommandsExceptDenied: alwaysAllowCommandsExceptDenied ?? false, destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? false, alwaysAllowMcp: alwaysAllowMcp ?? false, alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, @@ -2695,7 +2693,6 @@ export class ClineProvider alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, - alwaysAllowCommandsExceptDenied: stateValues.alwaysAllowCommandsExceptDenied ?? false, destructiveCommandGuardEnabled: stateValues.destructiveCommandGuardEnabled ?? false, alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 866b3354f7..70d9824156 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1011,22 +1011,21 @@ describe("ClineProvider", () => { expect(state).toHaveProperty("writeDelayMs") }) - test("getStateToPostToWebview returns the saved allow-all-except-denied setting", async () => { + test("getStateToPostToWebview returns the saved destructive command guard setting", async () => { await provider.resolveWebviewView(mockWebviewView) - await provider.contextProxy.setValue("alwaysAllowCommandsExceptDenied", true) + await provider.contextProxy.setValue("destructiveCommandGuardEnabled", true) const state = await provider.getStateToPostToWebview() - expect(state.alwaysAllowCommandsExceptDenied).toBe(true) + expect(state.destructiveCommandGuardEnabled).toBe(true) }) - test("getStateToPostToWebview returns the saved destructive command guard setting", async () => { + test("getStateToPostToWebview disables destructive command guard by default", async () => { await provider.resolveWebviewView(mockWebviewView) - await provider.contextProxy.setValue("destructiveCommandGuardEnabled", true) const state = await provider.getStateToPostToWebview() - expect(state.destructiveCommandGuardEnabled).toBe(true) + expect(state.destructiveCommandGuardEnabled).toBe(false) }) test("language is set to VSCode language", async () => { diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index b6dda274ef..5bcb6deb0d 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -42,7 +42,6 @@ interface CommandExecutionProps { export const CommandExecution = ({ executionId, text, icon, title, isDenied = false }: CommandExecutionProps) => { const { terminalShellIntegrationDisabled = false, - alwaysAllowCommandsExceptDenied = false, allowedCommands = [], deniedCommands = [], setAllowedCommands, @@ -247,7 +246,7 @@ export const CommandExecution = ({ executionId, text, icon, title, isDenied = fa - {command && command.trim() && !alwaysAllowCommandsExceptDenied && !isDenied && ( + {command && command.trim() && !isDenied && ( { expect(selector).toHaveTextContent("npm install express") }) - it("should hide the command pattern selector when all commands are auto-approved except denied commands", () => { - const state = { - ...mockExtensionState, - alwaysAllowCommandsExceptDenied: true, - } - - render( - - - , - ) - - expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() - }) - it("should hide the command pattern selector for a denied command", () => { render( diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index 28588a4235..29676f2299 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -28,7 +28,6 @@ type AutoApproveSettingsProps = HTMLAttributes & { alwaysAllowModeSwitch?: boolean alwaysAllowSubtasks?: boolean alwaysAllowExecute?: boolean - alwaysAllowCommandsExceptDenied?: boolean destructiveCommandGuardEnabled?: boolean alwaysAllowFollowupQuestions?: boolean followupAutoApproveTimeoutMs?: number @@ -46,7 +45,6 @@ type AutoApproveSettingsProps = HTMLAttributes & { | "alwaysAllowModeSwitch" | "alwaysAllowSubtasks" | "alwaysAllowExecute" - | "alwaysAllowCommandsExceptDenied" | "destructiveCommandGuardEnabled" | "alwaysAllowFollowupQuestions" | "followupAutoApproveTimeoutMs" @@ -67,7 +65,6 @@ export const AutoApproveSettings = ({ alwaysAllowModeSwitch, alwaysAllowSubtasks, alwaysAllowExecute, - alwaysAllowCommandsExceptDenied, destructiveCommandGuardEnabled, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs = 60000, @@ -282,25 +279,6 @@ export const AutoApproveSettings = ({
{t("settings:autoApprove.execute.label")}
- - - setCachedStateField("alwaysAllowCommandsExceptDenied", e.target.checked) - } - data-testid="always-allow-commands-except-denied-checkbox"> - - {t("settings:autoApprove.execute.allowAllExceptDenied.label")} - - -
- {t("settings:autoApprove.execute.allowAllExceptDenied.description")} -
-
- - {!alwaysAllowCommandsExceptDenied && ( + {!destructiveCommandGuardEnabled && ( <> ))} - - )} - {/* Denied Commands Section */} -
- - -
- {t("settings:autoApprove.execute.deniedCommandsDescription")} -
-
- -
- setDeniedCommandInput(e.target.value)} - onKeyDown={(e: any) => { - if (e.key === "Enter") { - e.preventDefault() - handleAddDeniedCommand() - } - }} - placeholder={t("settings:autoApprove.execute.deniedCommandPlaceholder")} - className="grow" - data-testid="denied-command-input" - /> - -
+ {/* Denied Commands Section */} + + +
+ {t("settings:autoApprove.execute.deniedCommandsDescription")} +
+
-
- {(deniedCommands ?? []).map((cmd, index) => ( +
+ setDeniedCommandInput(e.target.value)} + onKeyDown={(e: any) => { + if (e.key === "Enter") { + e.preventDefault() + handleAddDeniedCommand() + } + }} + placeholder={t("settings:autoApprove.execute.deniedCommandPlaceholder")} + className="grow" + data-testid="denied-command-input" + /> - ))} -
-
+
+ +
+ {(deniedCommands ?? []).map((cmd, index) => ( + + ))} +
+ + )} )} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index d93cb6d279..952c5615af 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -162,7 +162,6 @@ const SettingsView = forwardRef(({ onDone, t allowedMaxCost, language, alwaysAllowExecute, - alwaysAllowCommandsExceptDenied, destructiveCommandGuardEnabled, alwaysAllowMcp, alwaysAllowModeSwitch, @@ -389,7 +388,6 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? undefined, alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? undefined, alwaysAllowExecute: alwaysAllowExecute ?? undefined, - alwaysAllowCommandsExceptDenied: alwaysAllowCommandsExceptDenied ?? false, destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? false, alwaysAllowMcp, alwaysAllowModeSwitch, @@ -818,7 +816,6 @@ const SettingsView = forwardRef(({ onDone, t alwaysAllowModeSwitch={alwaysAllowModeSwitch} alwaysAllowSubtasks={alwaysAllowSubtasks} alwaysAllowExecute={alwaysAllowExecute} - alwaysAllowCommandsExceptDenied={alwaysAllowCommandsExceptDenied} destructiveCommandGuardEnabled={destructiveCommandGuardEnabled} alwaysAllowFollowupQuestions={alwaysAllowFollowupQuestions} followupAutoApproveTimeoutMs={followupAutoApproveTimeoutMs} diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx index 8dc409a7d1..b0ff054007 100644 --- a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx @@ -98,22 +98,6 @@ describe("AutoApproveSettings - Save/Discard contract", () => { expectNoImmediateUpdateSettings() }) - it("buffers the allow-all-except-denied setting without persisting before Save", () => { - const { setCachedStateField } = renderSettings() - - fireEvent.click(screen.getByTestId("always-allow-commands-except-denied-checkbox")) - - expect(setCachedStateField).toHaveBeenCalledWith("alwaysAllowCommandsExceptDenied", true) - expectNoImmediateUpdateSettings() - }) - - it("hides the allowed command list when allow-all-except-denied is enabled", () => { - renderSettings({ alwaysAllowCommandsExceptDenied: true }) - - expect(screen.queryByTestId("allowed-commands-heading")).not.toBeInTheDocument() - expect(screen.getByTestId("denied-commands-heading")).toBeInTheDocument() - }) - it("buffers the destructive command guard setting", () => { const { setCachedStateField } = renderSettings() @@ -123,11 +107,16 @@ describe("AutoApproveSettings - Save/Discard contract", () => { expectNoImmediateUpdateSettings() }) - it("disables the denied commands editor while destructive command guard is enabled", () => { + it("renders destructive command guard disabled by default", () => { + renderSettings() + + expect(screen.getByTestId("destructive-command-guard-checkbox")).not.toBeChecked() + }) + + it("hides Zoo command list editors while destructive command guard is enabled", () => { renderSettings({ destructiveCommandGuardEnabled: true, deniedCommands: ["rm -rf"] }) - expect(screen.getByTestId("denied-command-input")).toBeDisabled() - expect(screen.getByTestId("add-denied-command-button")).toBeDisabled() - expect(screen.getByTestId("remove-denied-command-0")).toBeDisabled() + expect(screen.queryByTestId("allowed-commands-heading")).not.toBeInTheDocument() + expect(screen.queryByTestId("denied-commands-heading")).not.toBeInTheDocument() }) }) diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 84133a53a5..ad37a51f8a 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "Executar", "description": "Executar automàticament comandes de terminal permeses sense requerir aprovació", - "allowAllExceptDenied": { - "label": "Aprova automàticament totes les comandes excepte les denegades", - "description": "Aprova automàticament qualsevol comanda que no coincideixi amb el prefix d'una comanda denegada. La llista de comandes permeses s'ignora mentre aquesta opció està activada. Les comandes malformades i les substitucions perilloses continuen requerint revisió." - }, "destructiveCommandGuard": { "label": "Activa la protecció contra ordres destructives", - "description": "Baixa i utilitza Destructive Command Guard (DCG) per a aquesta plataforma. Les ordres bloquejades per DCG requeriran la teva aprovació. La llista d'ordres denegades de Zoo es desactiva mentre aquesta opció està activa. L'executable baixat es conserva si la desactives." + "description": "Baixa i utilitza Destructive Command Guard (DCG) per a aquesta plataforma. Les ordres permeses per DCG s'executen automàticament. Les ordres bloquejades per DCG requereixen la teva aprovació. Les llistes d'ordres de Zoo es desactiven mentre aquesta opció està activa. L'executable baixat es conserva si la desactives." }, "allowedCommands": "Comandes d'auto-execució permeses", "allowedCommandsDescription": "Prefixos de comandes que poden ser executats automàticament quan \"Aprovar sempre operacions d'execució\" està habilitat. Afegeix * per permetre totes les comandes (usar amb precaució).", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index d2fdf3e17b..3ed71666f5 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "Ausführen", "description": "Erlaubte Terminal-Befehle automatisch ohne Genehmigung ausführen", - "allowAllExceptDenied": { - "label": "Alle Befehle außer verweigerten automatisch genehmigen", - "description": "Genehmigt automatisch jeden Befehl, der mit keinem Präfix eines verweigerten Befehls übereinstimmt. Die Liste erlaubter Befehle wird ignoriert, solange diese Einstellung aktiviert ist. Fehlerhafte Befehle und gefährliche Ersetzungen müssen weiterhin geprüft werden." - }, "destructiveCommandGuard": { "label": "Schutz vor destruktiven Befehlen aktivieren", - "description": "Lädt Destructive Command Guard (DCG) für diese Plattform herunter und verwendet es. Von DCG blockierte Befehle benötigen deine Zustimmung. Zoos Liste abgelehnter Befehle ist währenddessen deaktiviert. Die heruntergeladene ausführbare Datei bleibt erhalten, wenn du die Option ausschaltest." + "description": "Lädt Destructive Command Guard (DCG) für diese Plattform herunter und verwendet es. Von DCG erlaubte Befehle werden automatisch ausgeführt. Von DCG blockierte Befehle benötigen deine Zustimmung. Zoos Befehlslisten sind währenddessen deaktiviert. Die heruntergeladene ausführbare Datei bleibt erhalten, wenn du die Option ausschaltest." }, "allowedCommands": "Erlaubte Auto-Ausführungsbefehle", "allowedCommandsDescription": "Befehlspräfixe, die automatisch ausgeführt werden können, wenn 'Ausführungsoperationen immer genehmigen' aktiviert ist. Fügen Sie * hinzu, um alle Befehle zu erlauben (mit Vorsicht verwenden).", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index c268d604a1..bcdbea2543 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -406,13 +406,9 @@ "execute": { "label": "Execute", "description": "Automatically execute allowed terminal commands without requiring approval", - "allowAllExceptDenied": { - "label": "Auto-approve all commands except denied commands", - "description": "Auto-approve any command that does not match a denied command prefix. The allowed commands list is ignored while this setting is enabled. Malformed commands and dangerous substitutions still require review." - }, "destructiveCommandGuard": { "label": "Enable destructive command guard", - "description": "Download and use Destructive Command Guard (DCG) for this platform. Commands blocked by DCG will require your approval. Zoo's denied commands list is disabled while this is enabled. The downloaded executable is retained if you turn this off." + "description": "Download and use Destructive Command Guard (DCG) for this platform. Commands allowed by DCG run automatically. Commands blocked by DCG require your approval. Zoo's command lists are disabled while this is enabled. The downloaded executable is retained if you turn this off." }, "allowedCommands": "Allowed Auto-Execute Commands", "allowedCommandsDescription": "Command prefixes that can be auto-executed when \"Always approve execute operations\" is enabled. Add * to allow all commands (use with caution).", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index aa37f14cb2..a5f880c998 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "Ejecutar", "description": "Ejecutar automáticamente comandos de terminal permitidos sin requerir aprobación", - "allowAllExceptDenied": { - "label": "Autoaprobar todos los comandos excepto los denegados", - "description": "Aprueba automáticamente cualquier comando que no coincida con el prefijo de un comando denegado. La lista de comandos permitidos se ignora mientras esta opción esté habilitada. Los comandos con formato incorrecto y las sustituciones peligrosas aún requieren revisión." - }, "destructiveCommandGuard": { "label": "Activar la protección contra comandos destructivos", - "description": "Descarga y usa Destructive Command Guard (DCG) para esta plataforma. Los comandos bloqueados por DCG requerirán tu aprobación. La lista de comandos denegados de Zoo se desactiva mientras esta opción está habilitada. El ejecutable descargado se conserva si la desactivas." + "description": "Descarga y usa Destructive Command Guard (DCG) para esta plataforma. Los comandos permitidos por DCG se ejecutan automáticamente. Los comandos bloqueados por DCG requieren tu aprobación. Las listas de comandos de Zoo se desactivan mientras esta opción está habilitada. El ejecutable descargado se conserva si la desactivas." }, "allowedCommands": "Comandos de auto-ejecución permitidos", "allowedCommandsDescription": "Prefijos de comandos que pueden ser ejecutados automáticamente cuando \"Aprobar siempre operaciones de ejecución\" está habilitado. Añade * para permitir todos los comandos (usar con precaución).", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index ab92226ff1..cd5a5db804 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -329,13 +329,9 @@ "execute": { "label": "Exécuter", "description": "Exécuter automatiquement les commandes de terminal autorisées sans nécessiter d'approbation", - "allowAllExceptDenied": { - "label": "Approuver automatiquement toutes les commandes sauf celles refusées", - "description": "Approuve automatiquement toute commande qui ne correspond pas au préfixe d'une commande refusée. La liste des commandes autorisées est ignorée tant que ce paramètre est activé. Les commandes mal formées et les substitutions dangereuses nécessitent toujours une vérification." - }, "destructiveCommandGuard": { "label": "Activer la protection contre les commandes destructrices", - "description": "Télécharge et utilise Destructive Command Guard (DCG) pour cette plateforme. Les commandes bloquées par DCG nécessiteront ton approbation. La liste des commandes refusées de Zoo est désactivée tant que cette option est active. L'exécutable téléchargé est conservé si tu la désactives." + "description": "Télécharge et utilise Destructive Command Guard (DCG) pour cette plateforme. Les commandes autorisées par DCG s'exécutent automatiquement. Les commandes bloquées par DCG nécessitent ton approbation. Les listes de commandes de Zoo sont désactivées tant que cette option est active. L'exécutable téléchargé est conservé si tu la désactives." }, "allowedCommands": "Commandes auto-exécutables autorisées", "allowedCommandsDescription": "Préfixes de commandes qui peuvent être auto-exécutés lorsque \"Toujours approuver les opérations d'exécution\" est activé. Ajoutez * pour autoriser toutes les commandes (à utiliser avec précaution).", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 5c354ce6a0..8350322d72 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "निष्पादित करें", "description": "अनुमोदन की आवश्यकता के बिना स्वचालित रूप से अनुमत टर्मिनल कमांड निष्पादित करें", - "allowAllExceptDenied": { - "label": "अस्वीकृत कमांड को छोड़कर सभी कमांड स्वतः अनुमोदित करें", - "description": "ऐसे किसी भी कमांड को स्वतः अनुमोदित करता है जो किसी अस्वीकृत कमांड प्रीफिक्स से मेल नहीं खाता। यह सेटिंग सक्षम होने पर अनुमत कमांड की सूची अनदेखी की जाती है। गलत संरचना वाले कमांड और खतरनाक प्रतिस्थापन की अब भी समीक्षा आवश्यक है।" - }, "destructiveCommandGuard": { "label": "विनाशकारी कमांड सुरक्षा सक्षम करें", - "description": "इस प्लेटफ़ॉर्म के लिए Destructive Command Guard (DCG) डाउनलोड करके उपयोग करें। DCG द्वारा रोके गए कमांड चलाने के लिए आपकी मंज़ूरी आवश्यक होगी। इसके सक्षम रहने पर Zoo की अस्वीकृत कमांड सूची बंद रहेगी। इसे बंद करने पर डाउनलोड किया गया executable रखा जाएगा।" + "description": "इस प्लेटफ़ॉर्म के लिए Destructive Command Guard (DCG) डाउनलोड करके उपयोग करें। DCG द्वारा अनुमत कमांड अपने आप चलते हैं। DCG द्वारा रोके गए कमांड चलाने के लिए आपकी मंज़ूरी आवश्यक होगी। इसके सक्षम रहने पर Zoo की कमांड सूचियाँ बंद रहेंगी। इसे बंद करने पर डाउनलोड किया गया executable रखा जाएगा।" }, "allowedCommands": "अनुमत स्वतः-निष्पादन कमांड", "allowedCommandsDescription": "कमांड प्रीफिक्स जो स्वचालित रूप से निष्पादित किए जा सकते हैं जब \"निष्पादन ऑपरेशन हमेशा अनुमोदित करें\" सक्षम है। सभी कमांड की अनुमति देने के लिए * जोड़ें (सावधानी से उपयोग करें)।", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index f02b7e2fe8..f807ef3dfb 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "Eksekusi", "description": "Secara otomatis mengeksekusi perintah terminal yang diizinkan tanpa memerlukan persetujuan", - "allowAllExceptDenied": { - "label": "Setujui otomatis semua perintah kecuali perintah yang ditolak", - "description": "Setujui otomatis setiap perintah yang tidak cocok dengan prefiks perintah yang ditolak. Daftar perintah yang diizinkan akan diabaikan selama pengaturan ini aktif. Perintah dengan format yang salah dan substitusi berbahaya tetap memerlukan peninjauan." - }, "destructiveCommandGuard": { "label": "Aktifkan perlindungan perintah destruktif", - "description": "Unduh dan gunakan Destructive Command Guard (DCG) untuk platform ini. Perintah yang diblokir DCG akan memerlukan persetujuanmu. Daftar perintah yang ditolak Zoo dinonaktifkan selama opsi ini aktif. File executable yang diunduh tetap disimpan jika kamu menonaktifkannya." + "description": "Unduh dan gunakan Destructive Command Guard (DCG) untuk platform ini. Perintah yang diizinkan DCG dijalankan secara otomatis. Perintah yang diblokir DCG memerlukan persetujuanmu. Daftar perintah Zoo dinonaktifkan selama opsi ini aktif. File executable yang diunduh tetap disimpan jika kamu menonaktifkannya." }, "allowedCommands": "Perintah Auto-Execute yang Diizinkan", "allowedCommandsDescription": "Prefix perintah yang dapat di-auto-execute ketika \"Selalu setujui operasi eksekusi\" diaktifkan. Tambahkan * untuk mengizinkan semua perintah (gunakan dengan hati-hati).", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index ce9628dbae..4bc19dda6a 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "Esegui", "description": "Esegui automaticamente i comandi del terminale consentiti senza richiedere approvazione", - "allowAllExceptDenied": { - "label": "Approva automaticamente tutti i comandi tranne quelli negati", - "description": "Approva automaticamente qualsiasi comando che non corrisponde al prefisso di un comando negato. L'elenco dei comandi consentiti viene ignorato mentre questa impostazione è attiva. I comandi non validi e le sostituzioni pericolose richiedono comunque una revisione." - }, "destructiveCommandGuard": { "label": "Abilita la protezione dai comandi distruttivi", - "description": "Scarica e usa Destructive Command Guard (DCG) per questa piattaforma. I comandi bloccati da DCG richiederanno la tua approvazione. L'elenco dei comandi negati di Zoo viene disabilitato mentre questa opzione è attiva. L'eseguibile scaricato viene conservato se la disattivi." + "description": "Scarica e usa Destructive Command Guard (DCG) per questa piattaforma. I comandi consentiti da DCG vengono eseguiti automaticamente. I comandi bloccati da DCG richiedono la tua approvazione. Gli elenchi dei comandi di Zoo vengono disabilitati mentre questa opzione è attiva. L'eseguibile scaricato viene conservato se la disattivi." }, "allowedCommands": "Comandi di auto-esecuzione consentiti", "allowedCommandsDescription": "Prefissi di comando che possono essere auto-eseguiti quando \"Approva sempre operazioni di esecuzione\" è abilitato. Aggiungi * per consentire tutti i comandi (usare con cautela).", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 0d4ada0274..037f53593b 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "実行", "description": "承認なしで自動的に許可されたターミナルコマンドを実行", - "allowAllExceptDenied": { - "label": "拒否されたコマンド以外をすべて自動承認", - "description": "拒否されたコマンドのプレフィックスに一致しないコマンドを自動承認します。この設定が有効な間、許可されたコマンドのリストは無視されます。不正な形式のコマンドや危険な置換は、引き続き確認が必要です。" - }, "destructiveCommandGuard": { "label": "破壊的コマンドガードを有効にする", - "description": "このプラットフォーム用の Destructive Command Guard(DCG)をダウンロードして使用します。DCG がブロックしたコマンドの実行には承認が必要です。有効な間は Zoo の拒否コマンドリストが無効になります。無効にしても、ダウンロードした実行ファイルは保持されます。" + "description": "このプラットフォーム用の Destructive Command Guard(DCG)をダウンロードして使用します。DCG が許可したコマンドは自動的に実行されます。DCG がブロックしたコマンドの実行には承認が必要です。有効な間は Zoo のコマンドリストが無効になります。無効にしても、ダウンロードした実行ファイルは保持されます。" }, "allowedCommands": "許可された自動実行コマンド", "allowedCommandsDescription": "「実行操作を常に承認」が有効な場合に自動実行できるコマンドプレフィックス。すべてのコマンドを許可するには * を追加します(注意して使用してください)。", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 88bac0cd81..b51463198b 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "실행", "description": "승인 없이 자동으로 허용된 터미널 명령 실행", - "allowAllExceptDenied": { - "label": "거부된 명령을 제외한 모든 명령 자동 승인", - "description": "거부된 명령 접두사와 일치하지 않는 모든 명령을 자동 승인합니다. 이 설정이 활성화된 동안에는 허용된 명령 목록이 무시됩니다. 형식이 잘못된 명령과 위험한 치환은 계속 검토가 필요합니다." - }, "destructiveCommandGuard": { "label": "파괴적 명령어 보호 활성화", - "description": "이 플랫폼용 Destructive Command Guard(DCG)를 다운로드하여 사용합니다. DCG가 차단한 명령어는 실행 전 승인이 필요합니다. 이 옵션을 사용하는 동안 Zoo의 거부 명령어 목록은 비활성화됩니다. 옵션을 꺼도 다운로드한 실행 파일은 유지됩니다." + "description": "이 플랫폼용 Destructive Command Guard(DCG)를 다운로드하여 사용합니다. DCG가 허용한 명령어는 자동으로 실행됩니다. DCG가 차단한 명령어는 실행 전 승인이 필요합니다. 이 옵션을 사용하는 동안 Zoo의 명령어 목록은 비활성화됩니다. 옵션을 꺼도 다운로드한 실행 파일은 유지됩니다." }, "allowedCommands": "허용된 자동 실행 명령", "allowedCommandsDescription": "\"실행 작업 항상 승인\"이 활성화되었을 때 자동 실행될 수 있는 명령 접두사. 모든 명령을 허용하려면 * 추가(주의해서 사용)", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index e177e6cf8d..5d4f29e601 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "Uitvoeren", "description": "Automatisch toegestane terminalcommando's uitvoeren zonder goedkeuring", - "allowAllExceptDenied": { - "label": "Alle commando's behalve geweigerde automatisch goedkeuren", - "description": "Keurt elk commando automatisch goed dat niet overeenkomt met het voorvoegsel van een geweigerd commando. De lijst met toegestane commando's wordt genegeerd zolang deze instelling is ingeschakeld. Onjuist gevormde commando's en gevaarlijke vervangingen moeten nog steeds worden beoordeeld." - }, "destructiveCommandGuard": { "label": "Beveiliging tegen destructieve opdrachten inschakelen", - "description": "Download en gebruik Destructive Command Guard (DCG) voor dit platform. Voor opdrachten die DCG blokkeert, is jouw goedkeuring nodig. De lijst met geweigerde opdrachten van Zoo is uitgeschakeld zolang deze optie actief is. Het gedownloade uitvoerbare bestand blijft bewaard als je de optie uitschakelt." + "description": "Download en gebruik Destructive Command Guard (DCG) voor dit platform. Opdrachten die DCG toestaat, worden automatisch uitgevoerd. Voor opdrachten die DCG blokkeert, is jouw goedkeuring nodig. De opdrachtenlijsten van Zoo zijn uitgeschakeld zolang deze optie actief is. Het gedownloade uitvoerbare bestand blijft bewaard als je de optie uitschakelt." }, "allowedCommands": "Toegestane automatisch uit te voeren commando's", "allowedCommandsDescription": "Commando-prefixen die automatisch kunnen worden uitgevoerd als 'Altijd goedkeuren voor uitvoeren' is ingeschakeld. Voeg * toe om alle commando's toe te staan (gebruik met voorzichtigheid).", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 1d7bcda5b7..eae01f4b9b 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "Wykonaj", "description": "Automatycznie wykonuj dozwolone polecenia terminala bez konieczności zatwierdzania", - "allowAllExceptDenied": { - "label": "Automatycznie zatwierdzaj wszystkie polecenia poza odrzuconymi", - "description": "Automatycznie zatwierdza każde polecenie, które nie pasuje do prefiksu odrzuconego polecenia. Lista dozwolonych poleceń jest ignorowana, gdy to ustawienie jest włączone. Nieprawidłowo sformułowane polecenia i niebezpieczne podstawienia nadal wymagają sprawdzenia." - }, "destructiveCommandGuard": { "label": "Włącz ochronę przed destrukcyjnymi poleceniami", - "description": "Pobiera i używa Destructive Command Guard (DCG) dla tej platformy. Polecenia zablokowane przez DCG będą wymagały twojej zgody. Lista odrzuconych poleceń Zoo jest wyłączona, gdy ta opcja jest aktywna. Pobrany plik wykonywalny pozostaje na dysku po wyłączeniu opcji." + "description": "Pobiera i używa Destructive Command Guard (DCG) dla tej platformy. Polecenia dozwolone przez DCG są wykonywane automatycznie. Polecenia zablokowane przez DCG wymagają twojej zgody. Listy poleceń Zoo są wyłączone, gdy ta opcja jest aktywna. Pobrany plik wykonywalny pozostaje na dysku po wyłączeniu opcji." }, "allowedCommands": "Dozwolone polecenia auto-wykonania", "allowedCommandsDescription": "Prefiksy poleceń, które mogą być automatycznie wykonywane, gdy \"Zawsze zatwierdzaj operacje wykonania\" jest włączone. Dodaj * aby zezwolić na wszystkie polecenia (używaj z ostrożnością).", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 036a086c8f..89a19e493c 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "Executar", "description": "Executar automaticamente comandos de terminal permitidos sem exigir aprovação", - "allowAllExceptDenied": { - "label": "Aprovar automaticamente todos os comandos, exceto os negados", - "description": "Aprova automaticamente qualquer comando que não corresponda ao prefixo de um comando negado. A lista de comandos permitidos é ignorada enquanto esta configuração estiver ativada. Comandos malformados e substituições perigosas ainda exigem revisão." - }, "destructiveCommandGuard": { "label": "Ativar a proteção contra comandos destrutivos", - "description": "Baixa e usa o Destructive Command Guard (DCG) para esta plataforma. Comandos bloqueados pelo DCG precisarão da sua aprovação. A lista de comandos negados do Zoo fica desativada enquanto esta opção estiver ativa. O executável baixado é mantido se você desativá-la." + "description": "Baixa e usa o Destructive Command Guard (DCG) para esta plataforma. Comandos permitidos pelo DCG são executados automaticamente. Comandos bloqueados pelo DCG precisam da sua aprovação. As listas de comandos do Zoo ficam desativadas enquanto esta opção estiver ativa. O executável baixado é mantido se você desativá-la." }, "allowedCommands": "Comandos de auto-execução permitidos", "allowedCommandsDescription": "Prefixos de comando que podem ser auto-executados quando \"Aprovar sempre operações de execução\" está ativado. Adicione * para permitir todos os comandos (use com cautela).", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index eda893d455..3f1b1eaee5 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "Выполнение", "description": "Автоматически выполнять разрешённые команды терминала без необходимости одобрения", - "allowAllExceptDenied": { - "label": "Автоматически одобрять все команды, кроме запрещённых", - "description": "Автоматически одобряет любую команду, которая не совпадает с префиксом запрещённой команды. Пока эта настройка включена, список разрешённых команд игнорируется. Некорректные команды и опасные подстановки по-прежнему требуют проверки." - }, "destructiveCommandGuard": { "label": "Включить защиту от разрушительных команд", - "description": "Скачивает и использует Destructive Command Guard (DCG) для этой платформы. Команды, заблокированные DCG, потребуют твоего подтверждения. Пока эта настройка включена, список запрещённых команд Zoo отключён. Скачанный исполняемый файл сохраняется после отключения настройки." + "description": "Скачивает и использует Destructive Command Guard (DCG) для этой платформы. Разрешённые DCG команды выполняются автоматически. Команды, заблокированные DCG, требуют твоего подтверждения. Пока эта настройка включена, списки команд Zoo отключены. Скачанный исполняемый файл сохраняется после отключения настройки." }, "allowedCommands": "Разрешённые авто-выполняемые команды", "allowedCommandsDescription": "Префиксы команд, которые могут быть автоматически выполнены при включённом параметре \"Всегда одобрять выполнение операций\". Добавьте * для разрешения всех команд (используйте с осторожностью).", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index c069a02191..30ef6005ec 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "Yürüt", "description": "Onay gerektirmeden otomatik olarak izin verilen terminal komutlarını yürüt", - "allowAllExceptDenied": { - "label": "Reddedilenler dışındaki tüm komutları otomatik onayla", - "description": "Reddedilen bir komut önekiyle eşleşmeyen tüm komutları otomatik olarak onaylar. Bu ayar etkinken izin verilen komutlar listesi yok sayılır. Hatalı biçimlendirilmiş komutlar ve tehlikeli değiştirmeler yine de inceleme gerektirir." - }, "destructiveCommandGuard": { "label": "Yıkıcı komut korumasını etkinleştir", - "description": "Bu platform için Destructive Command Guard'ı (DCG) indirir ve kullanır. DCG tarafından engellenen komutlar onayını gerektirir. Bu seçenek etkinken Zoo'nun reddedilen komutlar listesi devre dışı bırakılır. Seçeneği kapatsan da indirilen çalıştırılabilir dosya korunur." + "description": "Bu platform için Destructive Command Guard'ı (DCG) indirir ve kullanır. DCG'nin izin verdiği komutlar otomatik olarak çalıştırılır. DCG tarafından engellenen komutlar onayını gerektirir. Bu seçenek etkinken Zoo'nun komut listeleri devre dışı bırakılır. Seçeneği kapatsan da indirilen çalıştırılabilir dosya korunur." }, "allowedCommands": "İzin Verilen Otomatik Yürütme Komutları", "allowedCommandsDescription": "\"Yürütme işlemlerini her zaman onayla\" etkinleştirildiğinde otomatik olarak yürütülebilen komut önekleri. Tüm komutlara izin vermek için * ekleyin (dikkatli kullanın).", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 29b3d9d36f..3291d19015 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "Thực thi", "description": "Tự động thực thi các lệnh terminal được phép mà không cần phê duyệt", - "allowAllExceptDenied": { - "label": "Tự động phê duyệt mọi lệnh ngoại trừ các lệnh bị từ chối", - "description": "Tự động phê duyệt mọi lệnh không khớp với tiền tố của lệnh bị từ chối. Danh sách lệnh được phép sẽ bị bỏ qua khi cài đặt này được bật. Các lệnh sai định dạng và phép thay thế nguy hiểm vẫn cần được xem xét." - }, "destructiveCommandGuard": { "label": "Bật bảo vệ khỏi lệnh phá hoại", - "description": "Tải xuống và sử dụng Destructive Command Guard (DCG) cho nền tảng này. Các lệnh bị DCG chặn sẽ cần bạn phê duyệt. Danh sách lệnh bị từ chối của Zoo sẽ bị vô hiệu hóa khi tùy chọn này được bật. Tệp thực thi đã tải xuống vẫn được giữ lại nếu bạn tắt tùy chọn." + "description": "Tải xuống và sử dụng Destructive Command Guard (DCG) cho nền tảng này. Các lệnh được DCG cho phép sẽ tự động chạy. Các lệnh bị DCG chặn cần bạn phê duyệt. Danh sách lệnh của Zoo sẽ bị vô hiệu hóa khi tùy chọn này được bật. Tệp thực thi đã tải xuống vẫn được giữ lại nếu bạn tắt tùy chọn." }, "allowedCommands": "Các lệnh tự động thực thi được phép", "allowedCommandsDescription": "Tiền tố lệnh có thể được tự động thực thi khi \"Luôn phê duyệt các hoạt động thực thi\" được bật. Thêm * để cho phép tất cả các lệnh (sử dụng cẩn thận).", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 2267c3ac12..c69238ed0d 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -328,13 +328,9 @@ "execute": { "label": "执行", "description": "自动执行白名单中的命令而无需批准", - "allowAllExceptDenied": { - "label": "自动批准除拒绝命令之外的所有命令", - "description": "自动批准所有不匹配拒绝命令前缀的命令。启用此设置时,将忽略允许命令列表。格式错误的命令和危险的替换仍需审核。" - }, "destructiveCommandGuard": { "label": "启用破坏性命令防护", - "description": "下载并使用适用于此平台的 Destructive Command Guard(DCG)。被 DCG 拦截的命令需要你批准后才能执行。启用后,Zoo 的拒绝命令列表将被禁用。关闭此选项时,已下载的可执行文件会保留。" + "description": "下载并使用适用于此平台的 Destructive Command Guard(DCG)。DCG 允许的命令会自动执行。被 DCG 拦截的命令需要你批准后才能执行。启用后,Zoo 的命令列表将被禁用。关闭此选项时,已下载的可执行文件会保留。" }, "allowedCommands": "命令白名单", "allowedCommandsDescription": "当\"自动批准命令行操作\"启用时可以自动执行的命令前缀。添加 * 以允许所有命令(谨慎使用)。", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 2dfccc21b0..2578cf517a 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -353,13 +353,9 @@ "execute": { "label": "執行命令", "description": "無需核准即可自動執行允許的終端機命令", - "allowAllExceptDenied": { - "label": "自動核准拒絕命令以外的所有命令", - "description": "自動核准所有不符合拒絕命令前綴的命令。啟用此設定時,允許的命令清單將被忽略。格式錯誤的命令和危險的替換仍需審查。" - }, "destructiveCommandGuard": { "label": "啟用破壞性命令防護", - "description": "下載並使用適用於此平台的 Destructive Command Guard(DCG)。被 DCG 阻擋的命令需要你核准後才能執行。啟用後,Zoo 的拒絕命令清單將停用。關閉此選項時,已下載的執行檔會保留。" + "description": "下載並使用適用於此平台的 Destructive Command Guard(DCG)。DCG 允許的命令會自動執行。被 DCG 阻擋的命令需要你核准後才能執行。啟用後,Zoo 的命令清單將停用。關閉此選項時,已下載的執行檔會保留。" }, "allowedCommands": "允許自動執行的命令", "allowedCommandsDescription": "啟用「始終核准執行」時,可自動執行的命令前綴。新增 * 可允許所有命令(請謹慎使用)。", From 5fac6839f8749e940cfc259e6b813eacb9133109 Mon Sep 17 00:00:00 2001 From: Naved Date: Tue, 28 Jul 2026 23:03:16 -0700 Subject: [PATCH 05/12] i18n and error ux --- src/core/tools/ExecuteCommandTool.ts | 21 ++++++-- .../__tests__/executeCommandTool.spec.ts | 50 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 4 +- src/i18n/locales/ca/common.json | 1 + src/i18n/locales/ca/tools.json | 8 +++ src/i18n/locales/de/common.json | 1 + src/i18n/locales/de/tools.json | 8 +++ src/i18n/locales/en/common.json | 1 + src/i18n/locales/en/tools.json | 8 +++ src/i18n/locales/es/common.json | 1 + src/i18n/locales/es/tools.json | 8 +++ src/i18n/locales/fr/common.json | 1 + src/i18n/locales/fr/tools.json | 8 +++ src/i18n/locales/hi/common.json | 1 + src/i18n/locales/hi/tools.json | 8 +++ src/i18n/locales/id/common.json | 1 + src/i18n/locales/id/tools.json | 8 +++ src/i18n/locales/it/common.json | 1 + src/i18n/locales/it/tools.json | 8 +++ src/i18n/locales/ja/common.json | 1 + src/i18n/locales/ja/tools.json | 8 +++ src/i18n/locales/ko/common.json | 1 + src/i18n/locales/ko/tools.json | 8 +++ src/i18n/locales/nl/common.json | 1 + src/i18n/locales/nl/tools.json | 8 +++ src/i18n/locales/pl/common.json | 1 + src/i18n/locales/pl/tools.json | 8 +++ src/i18n/locales/pt-BR/common.json | 1 + src/i18n/locales/pt-BR/tools.json | 8 +++ src/i18n/locales/ru/common.json | 1 + src/i18n/locales/ru/tools.json | 8 +++ src/i18n/locales/tr/common.json | 1 + src/i18n/locales/tr/tools.json | 8 +++ src/i18n/locales/vi/common.json | 1 + src/i18n/locales/vi/tools.json | 8 +++ src/i18n/locales/zh-CN/common.json | 1 + src/i18n/locales/zh-CN/tools.json | 8 +++ src/i18n/locales/zh-TW/common.json | 1 + src/i18n/locales/zh-TW/tools.json | 8 +++ 39 files changed, 232 insertions(+), 5 deletions(-) diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index d52605f637..e7ea9997b8 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -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 @@ -132,10 +148,7 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { const dcgResult = await runDcg(binaryPath, canonicalCommand, workingDirectory) dcgBlocked = dcgResult.decision === "deny" if (dcgResult.decision === "deny") { - await task.say( - "text", - `Destructive Command Guard blocked this command${dcgResult.reason ? `: ${dcgResult.reason}` : "."}${dcgResult.ruleId ? ` (Rule: ${dcgResult.ruleId})` : ""}`, - ) + await task.say("error", formatDcgBlockedMessage(dcgResult.reason, dcgResult.ruleId)) } } diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index c3236f3565..9d07f3ec3e 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -45,6 +45,14 @@ vitest.mock("../../../integrations/terminal/TerminalRegistry", () => ({ vitest.mock("../../task/Task") vitest.mock("../../prompts/responses") +const mockRunDcg = vitest.fn() +const mockGetDcgBinaryPath = vitest.fn() + +vitest.mock("../../../services/destructive-command-guard", () => ({ + runDcg: mockRunDcg, + getDcgBinaryPath: mockGetDcgBinaryPath, +})) + // Import the module import * as executeCommandModule from "../ExecuteCommandTool" const { executeCommandTool } = executeCommandModule @@ -96,6 +104,8 @@ describe("executeCommandTool", () => { mockAskApproval = vitest.fn().mockResolvedValue(true) mockHandleError = vitest.fn().mockResolvedValue(undefined) mockPushToolResult = vitest.fn() + mockRunDcg.mockResolvedValue({ decision: "allow" }) + mockGetDcgBinaryPath.mockReturnValue("/test/storage/dcg") // Setup vscode config mock const mockConfig = { @@ -199,6 +209,46 @@ describe("executeCommandTool", () => { }) describe("Error handling", () => { + it.each([ + [undefined, undefined, "executeCommand.destructiveCommandGuard.blocked"], + ["matches a destructive pattern", undefined, "executeCommand.destructiveCommandGuard.blockedWithReason"], + [undefined, "recursive-delete", "executeCommand.destructiveCommandGuard.blockedWithRule"], + [ + "matches a destructive pattern", + "recursive-delete", + "executeCommand.destructiveCommandGuard.blockedWithReasonAndRule", + ], + ])("selects the localized DCG block message for reason %s and rule %s", (reason, ruleId, expected) => { + expect(executeCommandModule.formatDcgBlockedMessage(reason, ruleId)).toBe(expected) + }) + + it("shows a DCG block message as an error before requesting explicit approval", async () => { + const provider = await mockCline.providerRef.deref() + provider.context = { globalStorageUri: { fsPath: "/test/storage" } } + provider.getState.mockResolvedValue({ + destructiveCommandGuardEnabled: true, + terminalShellIntegrationDisabled: true, + }) + mockRunDcg.mockResolvedValue({ + decision: "deny", + reason: "matches a destructive pattern", + ruleId: "recursive-delete", + }) + mockAskApproval.mockResolvedValue(false) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockCline.say).toHaveBeenCalledWith( + "error", + "executeCommand.destructiveCommandGuard.blockedWithReasonAndRule", + ) + expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test", undefined, true) + }) + it("should handle missing command parameter", async () => { // Setup mockToolUse.params.command = undefined diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index a11c3e9a14..b8b6fb03d3 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -688,7 +688,9 @@ export const webviewMessageHandler = async ( } catch (error) { message.updatedSettings.destructiveCommandGuardEnabled = false vscode.window.showErrorMessage( - `Unable to enable Destructive Command Guard: ${error instanceof Error ? error.message : String(error)}`, + t("common:errors.destructive_command_guard_enable_failed", { + error: error instanceof Error ? error.message : String(error), + }), ) } } diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index f988fa0c37..283a419c06 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -76,6 +76,7 @@ "url_fetch_failed": "Error en obtenir el contingut de la URL: {{error}}", "url_fetch_error_with_url": "Error en obtenir contingut per {{url}}: {{error}}", "command_timeout": "L'execució de la comanda ha superat el temps d'espera de {{seconds}} segons", + "destructive_command_guard_enable_failed": "No s'ha pogut activar Destructive Command Guard: {{error}}", "share_task_failed": "Ha fallat compartir la tasca. Si us plau, torna-ho a provar.", "share_no_active_task": "No hi ha cap tasca activa per compartir", "share_auth_required": "Es requereix autenticació. Si us plau, inicia sessió per compartir tasques.", diff --git a/src/i18n/locales/ca/tools.json b/src/i18n/locales/ca/tools.json index 93f363cf25..d76adf23bc 100644 --- a/src/i18n/locales/ca/tools.json +++ b/src/i18n/locales/ca/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Cercant '{{query}}' a la base de codi..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard ha bloquejat aquesta ordre.", + "blockedWithReason": "Destructive Command Guard ha bloquejat aquesta ordre. Missatge de DCG: {{reason}}", + "blockedWithRule": "Destructive Command Guard ha bloquejat aquesta ordre. (Regla: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard ha bloquejat aquesta ordre. Missatge de DCG: {{reason}} (Regla: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "No s'ha pogut crear una nova tasca a causa de restriccions de política." diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 03dc94ca22..ba28b8a2f5 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "Fehler beim Abrufen des URL-Inhalts: {{error}}", "url_fetch_error_with_url": "Fehler beim Abrufen des Inhalts für {{url}}: {{error}}", "command_timeout": "Zeitüberschreitung bei der Befehlsausführung nach {{seconds}} Sekunden", + "destructive_command_guard_enable_failed": "Destructive Command Guard konnte nicht aktiviert werden: {{error}}", "share_task_failed": "Teilen der Aufgabe fehlgeschlagen. Bitte versuche es erneut.", "share_no_active_task": "Keine aktive Aufgabe zum Teilen", "share_auth_required": "Authentifizierung erforderlich. Bitte melde dich an, um Aufgaben zu teilen.", diff --git a/src/i18n/locales/de/tools.json b/src/i18n/locales/de/tools.json index c1f358a01e..c3f17707ea 100644 --- a/src/i18n/locales/de/tools.json +++ b/src/i18n/locales/de/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Suche nach '{{query}}' im Codebase..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard hat diesen Befehl blockiert.", + "blockedWithReason": "Destructive Command Guard hat diesen Befehl blockiert. Nachricht von DCG: {{reason}}", + "blockedWithRule": "Destructive Command Guard hat diesen Befehl blockiert. (Regel: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard hat diesen Befehl blockiert. Nachricht von DCG: {{reason}} (Regel: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "Neue Aufgabe konnte aufgrund von Richtlinienbeschränkungen nicht erstellt werden." diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 190af9180a..da7933d774 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "Failed to fetch URL content: {{error}}", "url_fetch_error_with_url": "Error fetching content for {{url}}: {{error}}", "command_timeout": "Command execution timed out after {{seconds}} seconds", + "destructive_command_guard_enable_failed": "Unable to enable Destructive Command Guard: {{error}}", "share_task_failed": "Failed to share task. Please try again.", "share_no_active_task": "No active task to share", "share_auth_required": "Authentication required. Please sign in to share tasks.", diff --git a/src/i18n/locales/en/tools.json b/src/i18n/locales/en/tools.json index acde8f23bd..c29c717a87 100644 --- a/src/i18n/locales/en/tools.json +++ b/src/i18n/locales/en/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Searching for '{{query}}' in codebase..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard blocked this command.", + "blockedWithReason": "Destructive Command Guard blocked this command. Message from DCG: {{reason}}", + "blockedWithRule": "Destructive Command Guard blocked this command. (Rule: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard blocked this command. Message from DCG: {{reason}} (Rule: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "Failed to create new task due to policy restrictions." diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 4fcbd82a85..166d1ccad3 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "Error al obtener el contenido de la URL: {{error}}", "url_fetch_error_with_url": "Error al obtener contenido para {{url}}: {{error}}", "command_timeout": "La ejecución del comando superó el tiempo de espera de {{seconds}} segundos", + "destructive_command_guard_enable_failed": "No se pudo activar Destructive Command Guard: {{error}}", "share_task_failed": "Error al compartir la tarea. Por favor, inténtalo de nuevo.", "share_no_active_task": "No hay tarea activa para compartir", "share_auth_required": "Se requiere autenticación. Por favor, inicia sesión para compartir tareas.", diff --git a/src/i18n/locales/es/tools.json b/src/i18n/locales/es/tools.json index 48d6e4da39..7739a88834 100644 --- a/src/i18n/locales/es/tools.json +++ b/src/i18n/locales/es/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Buscando '{{query}}' en la base de código..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard bloqueó este comando.", + "blockedWithReason": "Destructive Command Guard bloqueó este comando. Mensaje de DCG: {{reason}}", + "blockedWithRule": "Destructive Command Guard bloqueó este comando. (Regla: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard bloqueó este comando. Mensaje de DCG: {{reason}} (Regla: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "No se pudo crear una nueva tarea debido a restricciones de política." diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index ece55c9b11..6638bfcb3d 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "Échec de récupération du contenu de l'URL : {{error}}", "url_fetch_error_with_url": "Erreur lors de la récupération du contenu pour {{url}} : {{error}}", "command_timeout": "L'exécution de la commande a expiré après {{seconds}} secondes", + "destructive_command_guard_enable_failed": "Impossible d'activer Destructive Command Guard : {{error}}", "share_task_failed": "Échec du partage de la tâche. Veuillez réessayer.", "share_no_active_task": "Aucune tâche active à partager", "share_auth_required": "Authentification requise. Veuillez vous connecter pour partager des tâches.", diff --git a/src/i18n/locales/fr/tools.json b/src/i18n/locales/fr/tools.json index 45d6ba3325..9788adc4d9 100644 --- a/src/i18n/locales/fr/tools.json +++ b/src/i18n/locales/fr/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Recherche de '{{query}}' dans la base de code..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard a bloqué cette commande.", + "blockedWithReason": "Destructive Command Guard a bloqué cette commande. Message de DCG : {{reason}}", + "blockedWithRule": "Destructive Command Guard a bloqué cette commande. (Règle : {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard a bloqué cette commande. Message de DCG : {{reason}} (Règle : {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "Impossible de créer une nouvelle tâche en raison de restrictions de politique." diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 42e46f0e9e..7dff57a1f7 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "URL सामग्री प्राप्त करने में त्रुटि: {{error}}", "url_fetch_error_with_url": "{{url}} के लिए सामग्री प्राप्त करने में त्रुटि: {{error}}", "command_timeout": "कमांड निष्पादन {{seconds}} सेकंड के बाद समय समाप्त हो गया", + "destructive_command_guard_enable_failed": "Destructive Command Guard को सक्षम नहीं किया जा सका: {{error}}", "share_task_failed": "कार्य साझा करने में विफल। कृपया पुनः प्रयास करें।", "share_no_active_task": "साझा करने के लिए कोई सक्रिय कार्य नहीं", "share_auth_required": "प्रमाणीकरण आवश्यक है। कार्य साझा करने के लिए कृपया साइन इन करें।", diff --git a/src/i18n/locales/hi/tools.json b/src/i18n/locales/hi/tools.json index e377c46f09..867d801d03 100644 --- a/src/i18n/locales/hi/tools.json +++ b/src/i18n/locales/hi/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "कोडबेस में '{{query}}' खोज रहा है..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard ने इस कमांड को ब्लॉक कर दिया।", + "blockedWithReason": "Destructive Command Guard ने इस कमांड को ब्लॉक कर दिया। DCG का संदेश: {{reason}}", + "blockedWithRule": "Destructive Command Guard ने इस कमांड को ब्लॉक कर दिया। (नियम: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard ने इस कमांड को ब्लॉक कर दिया। DCG का संदेश: {{reason}} (नियम: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "नीति प्रतिबंधों के कारण नया कार्य बनाने में विफल।" diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index 92dbf24171..971587893a 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "Gagal mengambil konten URL: {{error}}", "url_fetch_error_with_url": "Error mengambil konten untuk {{url}}: {{error}}", "command_timeout": "Eksekusi perintah waktu habis setelah {{seconds}} detik", + "destructive_command_guard_enable_failed": "Tidak dapat mengaktifkan Destructive Command Guard: {{error}}", "share_task_failed": "Gagal membagikan tugas. Silakan coba lagi.", "share_no_active_task": "Tidak ada tugas aktif untuk dibagikan", "share_auth_required": "Autentikasi diperlukan. Silakan masuk untuk berbagi tugas.", diff --git a/src/i18n/locales/id/tools.json b/src/i18n/locales/id/tools.json index 04d1d16daa..1a8362ce51 100644 --- a/src/i18n/locales/id/tools.json +++ b/src/i18n/locales/id/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Mencari '{{query}}' di codebase..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard memblokir perintah ini.", + "blockedWithReason": "Destructive Command Guard memblokir perintah ini. Pesan dari DCG: {{reason}}", + "blockedWithRule": "Destructive Command Guard memblokir perintah ini. (Aturan: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard memblokir perintah ini. Pesan dari DCG: {{reason}} (Aturan: {{ruleId}})" + } + }, "searchFiles": { "workspaceBoundaryError": "Tidak dapat mencari di luar workspace. Path '{{path}}' berada di luar workspace saat ini." }, diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index fd739f7d8d..07c4625e42 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "Errore nel recupero del contenuto URL: {{error}}", "url_fetch_error_with_url": "Errore nel recupero del contenuto per {{url}}: {{error}}", "command_timeout": "Esecuzione del comando scaduta dopo {{seconds}} secondi", + "destructive_command_guard_enable_failed": "Impossibile abilitare Destructive Command Guard: {{error}}", "share_task_failed": "Condivisione dell'attività fallita. Riprova.", "share_no_active_task": "Nessuna attività attiva da condividere", "share_auth_required": "Autenticazione richiesta. Accedi per condividere le attività.", diff --git a/src/i18n/locales/it/tools.json b/src/i18n/locales/it/tools.json index e384a0ea80..8df4f267bf 100644 --- a/src/i18n/locales/it/tools.json +++ b/src/i18n/locales/it/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Ricerca di '{{query}}' nella base di codice..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard ha bloccato questo comando.", + "blockedWithReason": "Destructive Command Guard ha bloccato questo comando. Messaggio da DCG: {{reason}}", + "blockedWithRule": "Destructive Command Guard ha bloccato questo comando. (Regola: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard ha bloccato questo comando. Messaggio da DCG: {{reason}} (Regola: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "Impossibile creare una nuova attività a causa di restrizioni di policy." diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 06c8fd4a05..ef3e512258 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "URLコンテンツの取得に失敗しました:{{error}}", "url_fetch_error_with_url": "{{url}} のコンテンツ取得エラー:{{error}}", "command_timeout": "コマンドの実行が{{seconds}}秒後にタイムアウトしました", + "destructive_command_guard_enable_failed": "Destructive Command Guard を有効にできませんでした: {{error}}", "share_task_failed": "タスクの共有に失敗しました", "share_no_active_task": "共有するアクティブなタスクがありません", "share_auth_required": "認証が必要です。タスクを共有するにはサインインしてください。", diff --git a/src/i18n/locales/ja/tools.json b/src/i18n/locales/ja/tools.json index f319b0b768..f8ed987d12 100644 --- a/src/i18n/locales/ja/tools.json +++ b/src/i18n/locales/ja/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "コードベースで '{{query}}' を検索中..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard がこのコマンドをブロックしました。", + "blockedWithReason": "Destructive Command Guard がこのコマンドをブロックしました。DCG からのメッセージ: {{reason}}", + "blockedWithRule": "Destructive Command Guard がこのコマンドをブロックしました。(ルール: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard がこのコマンドをブロックしました。DCG からのメッセージ: {{reason}}(ルール: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "ポリシー制限により新しいタスクを作成できませんでした。" diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 4b2944cfd9..290bc6a123 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "URL 콘텐츠 가져오기 실패: {{error}}", "url_fetch_error_with_url": "{{url}} 콘텐츠 가져오기 오류: {{error}}", "command_timeout": "명령 실행 시간이 {{seconds}}초 후 초과되었습니다", + "destructive_command_guard_enable_failed": "Destructive Command Guard를 활성화할 수 없습니다: {{error}}", "share_task_failed": "작업 공유에 실패했습니다", "share_no_active_task": "공유할 활성 작업이 없습니다", "share_auth_required": "인증이 필요합니다. 작업을 공유하려면 로그인하세요.", diff --git a/src/i18n/locales/ko/tools.json b/src/i18n/locales/ko/tools.json index 6b8e91726b..7149766678 100644 --- a/src/i18n/locales/ko/tools.json +++ b/src/i18n/locales/ko/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "코드베이스에서 '{{query}}' 검색 중..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard가 이 명령어를 차단했습니다.", + "blockedWithReason": "Destructive Command Guard가 이 명령어를 차단했습니다. DCG 메시지: {{reason}}", + "blockedWithRule": "Destructive Command Guard가 이 명령어를 차단했습니다. (규칙: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard가 이 명령어를 차단했습니다. DCG 메시지: {{reason}} (규칙: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "정책 제한으로 인해 새 작업을 생성하지 못했습니다." diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 96a921c6c1..a0f593d3c5 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "Fout bij ophalen van URL-inhoud: {{error}}", "url_fetch_error_with_url": "Fout bij ophalen van inhoud voor {{url}}: {{error}}", "command_timeout": "Time-out bij uitvoeren van commando na {{seconds}} seconden", + "destructive_command_guard_enable_failed": "Destructive Command Guard kan niet worden ingeschakeld: {{error}}", "share_task_failed": "Delen van taak mislukt", "share_no_active_task": "Geen actieve taak om te delen", "share_auth_required": "Authenticatie vereist. Log in om taken te delen.", diff --git a/src/i18n/locales/nl/tools.json b/src/i18n/locales/nl/tools.json index 5a5df015a7..f26f5bd68a 100644 --- a/src/i18n/locales/nl/tools.json +++ b/src/i18n/locales/nl/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Zoeken naar '{{query}}' in codebase..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard heeft deze opdracht geblokkeerd.", + "blockedWithReason": "Destructive Command Guard heeft deze opdracht geblokkeerd. Bericht van DCG: {{reason}}", + "blockedWithRule": "Destructive Command Guard heeft deze opdracht geblokkeerd. (Regel: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard heeft deze opdracht geblokkeerd. Bericht van DCG: {{reason}} (Regel: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "Kan geen nieuwe taak aanmaken vanwege beleidsbeperkingen." diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index f3e789e842..2a3f46fe43 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "Błąd pobierania zawartości URL: {{error}}", "url_fetch_error_with_url": "Błąd pobierania zawartości dla {{url}}: {{error}}", "command_timeout": "Przekroczono limit czasu wykonania polecenia po {{seconds}} sekundach", + "destructive_command_guard_enable_failed": "Nie udało się włączyć Destructive Command Guard: {{error}}", "share_task_failed": "Nie udało się udostępnić zadania", "share_no_active_task": "Brak aktywnego zadania do udostępnienia", "share_auth_required": "Wymagana autoryzacja. Zaloguj się, aby udostępniać zadania.", diff --git a/src/i18n/locales/pl/tools.json b/src/i18n/locales/pl/tools.json index 32a3e27f49..64743eade9 100644 --- a/src/i18n/locales/pl/tools.json +++ b/src/i18n/locales/pl/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Wyszukiwanie '{{query}}' w bazie kodu..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard zablokował to polecenie.", + "blockedWithReason": "Destructive Command Guard zablokował to polecenie. Komunikat od DCG: {{reason}}", + "blockedWithRule": "Destructive Command Guard zablokował to polecenie. (Reguła: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard zablokował to polecenie. Komunikat od DCG: {{reason}} (Reguła: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "Nie udało się utworzyć nowego zadania z powodu ograniczeń polityki." diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index ac5ec44f6b..4bd4cab2d8 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -76,6 +76,7 @@ "url_fetch_failed": "Falha ao buscar conteúdo da URL: {{error}}", "url_fetch_error_with_url": "Erro ao buscar conteúdo para {{url}}: {{error}}", "command_timeout": "A execução do comando excedeu o tempo limite após {{seconds}} segundos", + "destructive_command_guard_enable_failed": "Não foi possível ativar o Destructive Command Guard: {{error}}", "share_task_failed": "Falha ao compartilhar tarefa", "share_no_active_task": "Nenhuma tarefa ativa para compartilhar", "share_auth_required": "Autenticação necessária. Faça login para compartilhar tarefas.", diff --git a/src/i18n/locales/pt-BR/tools.json b/src/i18n/locales/pt-BR/tools.json index 28e950db9c..97b2581f04 100644 --- a/src/i18n/locales/pt-BR/tools.json +++ b/src/i18n/locales/pt-BR/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Pesquisando '{{query}}' na base de código..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "O Destructive Command Guard bloqueou este comando.", + "blockedWithReason": "O Destructive Command Guard bloqueou este comando. Mensagem do DCG: {{reason}}", + "blockedWithRule": "O Destructive Command Guard bloqueou este comando. (Regra: {{ruleId}})", + "blockedWithReasonAndRule": "O Destructive Command Guard bloqueou este comando. Mensagem do DCG: {{reason}} (Regra: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "Falha ao criar nova tarefa devido a restrições de política." diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 26d4b2032f..e05406bd1c 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "Ошибка получения содержимого URL: {{error}}", "url_fetch_error_with_url": "Ошибка получения содержимого для {{url}}: {{error}}", "command_timeout": "Время выполнения команды истекло через {{seconds}} секунд", + "destructive_command_guard_enable_failed": "Не удалось включить Destructive Command Guard: {{error}}", "share_task_failed": "Не удалось поделиться задачей", "share_no_active_task": "Нет активной задачи для совместного использования", "share_auth_required": "Требуется аутентификация. Войдите в систему для совместного доступа к задачам.", diff --git a/src/i18n/locales/ru/tools.json b/src/i18n/locales/ru/tools.json index 6d20010a45..42688f88bf 100644 --- a/src/i18n/locales/ru/tools.json +++ b/src/i18n/locales/ru/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Поиск '{{query}}' в кодовой базе..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard заблокировал эту команду.", + "blockedWithReason": "Destructive Command Guard заблокировал эту команду. Сообщение от DCG: {{reason}}", + "blockedWithRule": "Destructive Command Guard заблокировал эту команду. (Правило: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard заблокировал эту команду. Сообщение от DCG: {{reason}} (Правило: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "Не удалось создать новую задачу из-за ограничений политики." diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index bde000783a..c12ed4f2ee 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "URL içeriği getirme hatası: {{error}}", "url_fetch_error_with_url": "{{url}} için içerik getirme hatası: {{error}}", "command_timeout": "Komut çalıştırma {{seconds}} saniye sonra zaman aşımına uğradı", + "destructive_command_guard_enable_failed": "Destructive Command Guard etkinleştirilemedi: {{error}}", "share_task_failed": "Görev paylaşılamadı", "share_no_active_task": "Paylaşılacak aktif görev yok", "share_auth_required": "Kimlik doğrulama gerekli. Görevleri paylaşmak için lütfen giriş yapın.", diff --git a/src/i18n/locales/tr/tools.json b/src/i18n/locales/tr/tools.json index de447fcf21..0d5813f2ad 100644 --- a/src/i18n/locales/tr/tools.json +++ b/src/i18n/locales/tr/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Kod tabanında '{{query}}' aranıyor..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard bu komutu engelledi.", + "blockedWithReason": "Destructive Command Guard bu komutu engelledi. DCG mesajı: {{reason}}", + "blockedWithRule": "Destructive Command Guard bu komutu engelledi. (Kural: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard bu komutu engelledi. DCG mesajı: {{reason}} (Kural: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "Politika kısıtlamaları nedeniyle yeni görev oluşturulamadı." diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 2a9433c506..7cc6354615 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "Lỗi lấy nội dung URL: {{error}}", "url_fetch_error_with_url": "Lỗi lấy nội dung cho {{url}}: {{error}}", "command_timeout": "Thực thi lệnh đã hết thời gian chờ sau {{seconds}} giây", + "destructive_command_guard_enable_failed": "Không thể bật Destructive Command Guard: {{error}}", "share_task_failed": "Không thể chia sẻ nhiệm vụ", "share_no_active_task": "Không có nhiệm vụ hoạt động để chia sẻ", "share_auth_required": "Cần xác thực. Vui lòng đăng nhập để chia sẻ nhiệm vụ.", diff --git a/src/i18n/locales/vi/tools.json b/src/i18n/locales/vi/tools.json index ec43f2b39e..301fc6a3dc 100644 --- a/src/i18n/locales/vi/tools.json +++ b/src/i18n/locales/vi/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "Đang tìm kiếm '{{query}}' trong cơ sở mã..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard đã chặn lệnh này.", + "blockedWithReason": "Destructive Command Guard đã chặn lệnh này. Thông báo từ DCG: {{reason}}", + "blockedWithRule": "Destructive Command Guard đã chặn lệnh này. (Quy tắc: {{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard đã chặn lệnh này. Thông báo từ DCG: {{reason}} (Quy tắc: {{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "Không thể tạo nhiệm vụ mới do hạn chế chính sách." diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index a1d0b06e89..57a208cc10 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -77,6 +77,7 @@ "url_fetch_failed": "获取 URL 内容失败:{{error}}", "url_fetch_error_with_url": "获取 {{url}} 内容时出错:{{error}}", "command_timeout": "命令执行超时,{{seconds}} 秒后", + "destructive_command_guard_enable_failed": "无法启用 Destructive Command Guard:{{error}}", "share_task_failed": "分享任务失败。请重试。", "share_no_active_task": "没有活跃任务可分享", "share_auth_required": "需要身份验证。请登录以分享任务。", diff --git a/src/i18n/locales/zh-CN/tools.json b/src/i18n/locales/zh-CN/tools.json index d5a5701f3d..80342c1891 100644 --- a/src/i18n/locales/zh-CN/tools.json +++ b/src/i18n/locales/zh-CN/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "正在搜索代码库中的 '{{query}}'..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard 已拦截此命令。", + "blockedWithReason": "Destructive Command Guard 已拦截此命令。来自 DCG 的消息:{{reason}}", + "blockedWithRule": "Destructive Command Guard 已拦截此命令。(规则:{{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard 已拦截此命令。来自 DCG 的消息:{{reason}}(规则:{{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "由于策略限制,无法创建新任务。" diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 92435e42ff..cdd8ddee67 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -72,6 +72,7 @@ "url_fetch_failed": "取得 URL 內容失敗:{{error}}", "url_fetch_error_with_url": "取得 {{url}} 內容時發生錯誤:{{error}}", "command_timeout": "命令執行超時,{{seconds}} 秒後", + "destructive_command_guard_enable_failed": "無法啟用 Destructive Command Guard:{{error}}", "share_task_failed": "分享工作失敗。請重試。", "share_no_active_task": "沒有活躍的工作可分享", "share_auth_required": "需要身份驗證。請登入以分享工作。", diff --git a/src/i18n/locales/zh-TW/tools.json b/src/i18n/locales/zh-TW/tools.json index 02623543b9..e090c2225c 100644 --- a/src/i18n/locales/zh-TW/tools.json +++ b/src/i18n/locales/zh-TW/tools.json @@ -13,6 +13,14 @@ "codebaseSearch": { "approval": "正在搜尋程式碼庫中的「{{query}}」..." }, + "executeCommand": { + "destructiveCommandGuard": { + "blocked": "Destructive Command Guard 已阻擋此命令。", + "blockedWithReason": "Destructive Command Guard 已阻擋此命令。來自 DCG 的訊息:{{reason}}", + "blockedWithRule": "Destructive Command Guard 已阻擋此命令。(規則:{{ruleId}})", + "blockedWithReasonAndRule": "Destructive Command Guard 已阻擋此命令。來自 DCG 的訊息:{{reason}}(規則:{{ruleId}})" + } + }, "newTask": { "errors": { "policy_restriction": "由於政策限制,無法建立新工作。" From bce10d6e9a03da41c4b13535067f3f767fde8f90 Mon Sep 17 00:00:00 2001 From: Naved Date: Tue, 28 Jul 2026 23:11:25 -0700 Subject: [PATCH 06/12] remove auto approve commands from command window --- .../src/components/chat/CommandExecution.tsx | 3 ++- .../chat/__tests__/CommandExecution.spec.tsx | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 5bcb6deb0d..4d563392a7 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -42,6 +42,7 @@ interface CommandExecutionProps { export const CommandExecution = ({ executionId, text, icon, title, isDenied = false }: CommandExecutionProps) => { const { terminalShellIntegrationDisabled = false, + destructiveCommandGuardEnabled = false, allowedCommands = [], deniedCommands = [], setAllowedCommands, @@ -246,7 +247,7 @@ export const CommandExecution = ({ executionId, text, icon, title, isDenied = fa - {command && command.trim() && !isDenied && ( + {command && command.trim() && !destructiveCommandGuardEnabled && !isDenied && ( ({ // Mock ExtensionStateContext const mockExtensionState = { terminalShellIntegrationDisabled: false, + destructiveCommandGuardEnabled: false, allowedCommands: ["npm"], deniedCommands: ["rm"], setAllowedCommands: vi.fn(), @@ -110,6 +111,22 @@ describe("CommandExecution", () => { expect(selector).toHaveTextContent("npm install express") }) + it("should hide the command pattern selector while destructive command guard is enabled", () => { + const state = { + ...mockExtensionState, + destructiveCommandGuardEnabled: true, + } + + render( + + + , + ) + + expect(screen.getByTestId("code-block")).toHaveTextContent("npm install express") + expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument() + }) + it("should hide the command pattern selector for a denied command", () => { render( From 9b77aa776525802dabc83405aae00686a2125cc0 Mon Sep 17 00:00:00 2001 From: Naved Date: Wed, 29 Jul 2026 00:25:01 -0700 Subject: [PATCH 07/12] feat: harden destructive command guard integration --- packages/types/src/__tests__/message.test.ts | 15 ++ packages/types/src/global-settings.ts | 2 + src/core/auto-approval/__tests__/dcg.spec.ts | 6 + src/core/tools/ExecuteCommandTool.ts | 10 +- .../__tests__/executeCommandTool.spec.ts | 65 ++++++- src/core/webview/ClineProvider.ts | 6 +- src/i18n/locales/ca/common.json | 3 + src/i18n/locales/de/common.json | 3 + src/i18n/locales/en/common.json | 3 + src/i18n/locales/es/common.json | 3 + src/i18n/locales/fr/common.json | 3 + src/i18n/locales/hi/common.json | 3 + src/i18n/locales/id/common.json | 3 + src/i18n/locales/it/common.json | 3 + src/i18n/locales/ja/common.json | 3 + src/i18n/locales/ko/common.json | 3 + src/i18n/locales/nl/common.json | 3 + src/i18n/locales/pl/common.json | 3 + src/i18n/locales/pt-BR/common.json | 3 + src/i18n/locales/ru/common.json | 3 + src/i18n/locales/tr/common.json | 3 + src/i18n/locales/vi/common.json | 3 + src/i18n/locales/zh-CN/common.json | 3 + src/i18n/locales/zh-TW/common.json | 3 + .../__tests__/manager.spec.ts | 159 +++++++++++++++++- .../__tests__/runner.spec.ts | 126 ++++++++++++++ .../destructive-command-guard/constants.ts | 6 +- .../destructive-command-guard/manager.ts | 130 +++++++++++--- webview-ui/src/components/chat/ChatRow.tsx | 8 +- .../src/components/chat/CommandExecution.tsx | 4 +- webview-ui/src/i18n/locales/de/settings.json | 2 +- webview-ui/src/i18n/locales/fr/settings.json | 2 +- 32 files changed, 554 insertions(+), 41 deletions(-) create mode 100644 src/services/destructive-command-guard/__tests__/runner.spec.ts diff --git a/packages/types/src/__tests__/message.test.ts b/packages/types/src/__tests__/message.test.ts index b9b5fbdaea..1fad7c27ed 100644 --- a/packages/types/src/__tests__/message.test.ts +++ b/packages/types/src/__tests__/message.test.ts @@ -2,6 +2,7 @@ import { clineAsks, + clineMessageSchema, getCompletionCheckpoint, isIdleAsk, isInteractiveAsk, @@ -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[] = [ diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 606dd79458..dc3ea072fd 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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. * diff --git a/src/core/auto-approval/__tests__/dcg.spec.ts b/src/core/auto-approval/__tests__/dcg.spec.ts index ae92af2758..db61a5d7db 100644 --- a/src/core/auto-approval/__tests__/dcg.spec.ts +++ b/src/core/auto-approval/__tests__/dcg.spec.ts @@ -37,6 +37,12 @@ describe("Destructive Command Guard auto-approval precedence", () => { }) }) + 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 } diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index e7ea9997b8..41cae06c73 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -135,11 +135,13 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { const providerState = await provider?.getState() let dcgBlocked = false if (providerState?.destructiveCommandGuardEnabled === true) { - const { getDcgBinaryPath, runDcg } = await import("../../services/destructive-command-guard") - const binaryPath = provider ? getDcgBinaryPath(provider.context.globalStorageUri.fsPath) : undefined - if (!binaryPath) { - throw new Error("Destructive Command Guard is enabled but is not available for this platform") + 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) const workingDirectory = customCwd ? path.isAbsolute(customCwd) ? customCwd diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 9d07f3ec3e..8ee58a98b2 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -46,11 +46,11 @@ vitest.mock("../../task/Task") vitest.mock("../../prompts/responses") const mockRunDcg = vitest.fn() -const mockGetDcgBinaryPath = vitest.fn() +const mockEnsureDcgInstalled = vitest.fn() vitest.mock("../../../services/destructive-command-guard", () => ({ runDcg: mockRunDcg, - getDcgBinaryPath: mockGetDcgBinaryPath, + ensureDcgInstalled: mockEnsureDcgInstalled, })) // Import the module @@ -105,7 +105,7 @@ describe("executeCommandTool", () => { mockHandleError = vitest.fn().mockResolvedValue(undefined) mockPushToolResult = vitest.fn() mockRunDcg.mockResolvedValue({ decision: "allow" }) - mockGetDcgBinaryPath.mockReturnValue("/test/storage/dcg") + mockEnsureDcgInstalled.mockResolvedValue("/test/storage/dcg") // Setup vscode config mock const mockConfig = { @@ -249,6 +249,65 @@ describe("executeCommandTool", () => { expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test", undefined, true) }) + it("requests normal approval when DCG allows the command", async () => { + const provider = await mockCline.providerRef.deref() + provider.context = { globalStorageUri: { fsPath: "/test/storage" } } + provider.getState.mockResolvedValue({ + destructiveCommandGuardEnabled: true, + terminalShellIntegrationDisabled: true, + }) + mockRunDcg.mockResolvedValue({ decision: "allow" }) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockAskApproval).toHaveBeenCalledWith("command", "echo test") + }) + + it("installs or updates DCG before evaluating an enabled command", async () => { + const provider = await mockCline.providerRef.deref() + provider.context = { globalStorageUri: { fsPath: "/test/storage" } } + provider.getState.mockResolvedValue({ + destructiveCommandGuardEnabled: true, + terminalShellIntegrationDisabled: true, + }) + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockEnsureDcgInstalled).toHaveBeenCalledWith("/test/storage") + expect(mockRunDcg).toHaveBeenCalledWith("/test/storage/dcg", "echo test", "/test/workspace") + }) + + it("fails closed when the DCG install or update fails", async () => { + const provider = await mockCline.providerRef.deref() + provider.context = { globalStorageUri: { fsPath: "/test/storage" } } + provider.getState.mockResolvedValue({ + destructiveCommandGuardEnabled: true, + terminalShellIntegrationDisabled: true, + }) + mockEnsureDcgInstalled.mockRejectedValue(new Error("download failed")) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith( + "executing command", + expect.objectContaining({ message: "download failed" }), + ) + expect(mockRunDcg).not.toHaveBeenCalled() + expect(mockAskApproval).not.toHaveBeenCalled() + expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled() + }) + it("should handle missing command parameter", async () => { // Setup mockToolUse.params.command = undefined diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4e8c530ccc..db1f634ec0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -42,6 +42,7 @@ import { openRouterDefaultModelId, DEFAULT_WRITE_DELAY_MS, DEFAULT_DIFF_FUZZY_THRESHOLD, + DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, @@ -2460,7 +2461,7 @@ export class ClineProvider alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false, alwaysAllowExecute: alwaysAllowExecute ?? false, - destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? false, + destructiveCommandGuardEnabled: destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, alwaysAllowMcp: alwaysAllowMcp ?? false, alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: alwaysAllowSubtasks ?? false, @@ -2693,7 +2694,8 @@ export class ClineProvider alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, - destructiveCommandGuardEnabled: stateValues.destructiveCommandGuardEnabled ?? false, + destructiveCommandGuardEnabled: + stateValues.destructiveCommandGuardEnabled ?? DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED, alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 283a419c06..f0a0039a6d 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -76,6 +76,9 @@ "url_fetch_failed": "Error en obtenir el contingut de la URL: {{error}}", "url_fetch_error_with_url": "Error en obtenir contingut per {{url}}: {{error}}", "command_timeout": "L'execució de la comanda ha superat el temps d'espera de {{seconds}} segons", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard està activat, però no està disponible per a aquesta plataforma" + }, "destructive_command_guard_enable_failed": "No s'ha pogut activar Destructive Command Guard: {{error}}", "share_task_failed": "Ha fallat compartir la tasca. Si us plau, torna-ho a provar.", "share_no_active_task": "No hi ha cap tasca activa per compartir", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index ba28b8a2f5..f802dd7cb7 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "Fehler beim Abrufen des URL-Inhalts: {{error}}", "url_fetch_error_with_url": "Fehler beim Abrufen des Inhalts für {{url}}: {{error}}", "command_timeout": "Zeitüberschreitung bei der Befehlsausführung nach {{seconds}} Sekunden", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard ist aktiviert, aber für diese Plattform nicht verfügbar" + }, "destructive_command_guard_enable_failed": "Destructive Command Guard konnte nicht aktiviert werden: {{error}}", "share_task_failed": "Teilen der Aufgabe fehlgeschlagen. Bitte versuche es erneut.", "share_no_active_task": "Keine aktive Aufgabe zum Teilen", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index da7933d774..05d17e430d 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "Failed to fetch URL content: {{error}}", "url_fetch_error_with_url": "Error fetching content for {{url}}: {{error}}", "command_timeout": "Command execution timed out after {{seconds}} seconds", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard is enabled but is not available for this platform" + }, "destructive_command_guard_enable_failed": "Unable to enable Destructive Command Guard: {{error}}", "share_task_failed": "Failed to share task. Please try again.", "share_no_active_task": "No active task to share", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 166d1ccad3..4c8db1797c 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "Error al obtener el contenido de la URL: {{error}}", "url_fetch_error_with_url": "Error al obtener contenido para {{url}}: {{error}}", "command_timeout": "La ejecución del comando superó el tiempo de espera de {{seconds}} segundos", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard está activado, pero no está disponible para esta plataforma" + }, "destructive_command_guard_enable_failed": "No se pudo activar Destructive Command Guard: {{error}}", "share_task_failed": "Error al compartir la tarea. Por favor, inténtalo de nuevo.", "share_no_active_task": "No hay tarea activa para compartir", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 6638bfcb3d..5d38125cfa 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "Échec de récupération du contenu de l'URL : {{error}}", "url_fetch_error_with_url": "Erreur lors de la récupération du contenu pour {{url}} : {{error}}", "command_timeout": "L'exécution de la commande a expiré après {{seconds}} secondes", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard est activé, mais n'est pas disponible pour cette plateforme" + }, "destructive_command_guard_enable_failed": "Impossible d'activer Destructive Command Guard : {{error}}", "share_task_failed": "Échec du partage de la tâche. Veuillez réessayer.", "share_no_active_task": "Aucune tâche active à partager", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 7dff57a1f7..47aac65cc0 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "URL सामग्री प्राप्त करने में त्रुटि: {{error}}", "url_fetch_error_with_url": "{{url}} के लिए सामग्री प्राप्त करने में त्रुटि: {{error}}", "command_timeout": "कमांड निष्पादन {{seconds}} सेकंड के बाद समय समाप्त हो गया", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard सक्षम है, लेकिन इस प्लेटफ़ॉर्म के लिए उपलब्ध नहीं है" + }, "destructive_command_guard_enable_failed": "Destructive Command Guard को सक्षम नहीं किया जा सका: {{error}}", "share_task_failed": "कार्य साझा करने में विफल। कृपया पुनः प्रयास करें।", "share_no_active_task": "साझा करने के लिए कोई सक्रिय कार्य नहीं", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index 971587893a..dd770878b0 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "Gagal mengambil konten URL: {{error}}", "url_fetch_error_with_url": "Error mengambil konten untuk {{url}}: {{error}}", "command_timeout": "Eksekusi perintah waktu habis setelah {{seconds}} detik", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard diaktifkan, tetapi tidak tersedia untuk platform ini" + }, "destructive_command_guard_enable_failed": "Tidak dapat mengaktifkan Destructive Command Guard: {{error}}", "share_task_failed": "Gagal membagikan tugas. Silakan coba lagi.", "share_no_active_task": "Tidak ada tugas aktif untuk dibagikan", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 07c4625e42..c1bee82189 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "Errore nel recupero del contenuto URL: {{error}}", "url_fetch_error_with_url": "Errore nel recupero del contenuto per {{url}}: {{error}}", "command_timeout": "Esecuzione del comando scaduta dopo {{seconds}} secondi", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard è abilitato, ma non è disponibile per questa piattaforma" + }, "destructive_command_guard_enable_failed": "Impossibile abilitare Destructive Command Guard: {{error}}", "share_task_failed": "Condivisione dell'attività fallita. Riprova.", "share_no_active_task": "Nessuna attività attiva da condividere", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index ef3e512258..93a60e293e 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "URLコンテンツの取得に失敗しました:{{error}}", "url_fetch_error_with_url": "{{url}} のコンテンツ取得エラー:{{error}}", "command_timeout": "コマンドの実行が{{seconds}}秒後にタイムアウトしました", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard は有効ですが、このプラットフォームでは利用できません" + }, "destructive_command_guard_enable_failed": "Destructive Command Guard を有効にできませんでした: {{error}}", "share_task_failed": "タスクの共有に失敗しました", "share_no_active_task": "共有するアクティブなタスクがありません", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 290bc6a123..b20d3c57cb 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "URL 콘텐츠 가져오기 실패: {{error}}", "url_fetch_error_with_url": "{{url}} 콘텐츠 가져오기 오류: {{error}}", "command_timeout": "명령 실행 시간이 {{seconds}}초 후 초과되었습니다", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard가 활성화되어 있지만 이 플랫폼에서는 사용할 수 없습니다" + }, "destructive_command_guard_enable_failed": "Destructive Command Guard를 활성화할 수 없습니다: {{error}}", "share_task_failed": "작업 공유에 실패했습니다", "share_no_active_task": "공유할 활성 작업이 없습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index a0f593d3c5..ebedc3322f 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "Fout bij ophalen van URL-inhoud: {{error}}", "url_fetch_error_with_url": "Fout bij ophalen van inhoud voor {{url}}: {{error}}", "command_timeout": "Time-out bij uitvoeren van commando na {{seconds}} seconden", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard is ingeschakeld, maar is niet beschikbaar voor dit platform" + }, "destructive_command_guard_enable_failed": "Destructive Command Guard kan niet worden ingeschakeld: {{error}}", "share_task_failed": "Delen van taak mislukt", "share_no_active_task": "Geen actieve taak om te delen", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 2a3f46fe43..c1f2974813 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "Błąd pobierania zawartości URL: {{error}}", "url_fetch_error_with_url": "Błąd pobierania zawartości dla {{url}}: {{error}}", "command_timeout": "Przekroczono limit czasu wykonania polecenia po {{seconds}} sekundach", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard jest włączony, ale nie jest dostępny dla tej platformy" + }, "destructive_command_guard_enable_failed": "Nie udało się włączyć Destructive Command Guard: {{error}}", "share_task_failed": "Nie udało się udostępnić zadania", "share_no_active_task": "Brak aktywnego zadania do udostępnienia", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 4bd4cab2d8..44f8081f6e 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -76,6 +76,9 @@ "url_fetch_failed": "Falha ao buscar conteúdo da URL: {{error}}", "url_fetch_error_with_url": "Erro ao buscar conteúdo para {{url}}: {{error}}", "command_timeout": "A execução do comando excedeu o tempo limite após {{seconds}} segundos", + "destructiveCommandGuard": { + "unavailable": "O Destructive Command Guard está ativado, mas não está disponível para esta plataforma" + }, "destructive_command_guard_enable_failed": "Não foi possível ativar o Destructive Command Guard: {{error}}", "share_task_failed": "Falha ao compartilhar tarefa", "share_no_active_task": "Nenhuma tarefa ativa para compartilhar", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index e05406bd1c..3dc402adff 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "Ошибка получения содержимого URL: {{error}}", "url_fetch_error_with_url": "Ошибка получения содержимого для {{url}}: {{error}}", "command_timeout": "Время выполнения команды истекло через {{seconds}} секунд", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard включён, но недоступен для этой платформы" + }, "destructive_command_guard_enable_failed": "Не удалось включить Destructive Command Guard: {{error}}", "share_task_failed": "Не удалось поделиться задачей", "share_no_active_task": "Нет активной задачи для совместного использования", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index c12ed4f2ee..2707945fe8 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "URL içeriği getirme hatası: {{error}}", "url_fetch_error_with_url": "{{url}} için içerik getirme hatası: {{error}}", "command_timeout": "Komut çalıştırma {{seconds}} saniye sonra zaman aşımına uğradı", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard etkin, ancak bu platformda kullanılamıyor" + }, "destructive_command_guard_enable_failed": "Destructive Command Guard etkinleştirilemedi: {{error}}", "share_task_failed": "Görev paylaşılamadı", "share_no_active_task": "Paylaşılacak aktif görev yok", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 7cc6354615..ab6bd714c2 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "Lỗi lấy nội dung URL: {{error}}", "url_fetch_error_with_url": "Lỗi lấy nội dung cho {{url}}: {{error}}", "command_timeout": "Thực thi lệnh đã hết thời gian chờ sau {{seconds}} giây", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard đã được bật nhưng không khả dụng cho nền tảng này" + }, "destructive_command_guard_enable_failed": "Không thể bật Destructive Command Guard: {{error}}", "share_task_failed": "Không thể chia sẻ nhiệm vụ", "share_no_active_task": "Không có nhiệm vụ hoạt động để chia sẻ", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 57a208cc10..0af3875a5c 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -77,6 +77,9 @@ "url_fetch_failed": "获取 URL 内容失败:{{error}}", "url_fetch_error_with_url": "获取 {{url}} 内容时出错:{{error}}", "command_timeout": "命令执行超时,{{seconds}} 秒后", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard 已启用,但不适用于此平台" + }, "destructive_command_guard_enable_failed": "无法启用 Destructive Command Guard:{{error}}", "share_task_failed": "分享任务失败。请重试。", "share_no_active_task": "没有活跃任务可分享", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index cdd8ddee67..13832974bf 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -72,6 +72,9 @@ "url_fetch_failed": "取得 URL 內容失敗:{{error}}", "url_fetch_error_with_url": "取得 {{url}} 內容時發生錯誤:{{error}}", "command_timeout": "命令執行超時,{{seconds}} 秒後", + "destructiveCommandGuard": { + "unavailable": "Destructive Command Guard 已啟用,但不適用於此平台" + }, "destructive_command_guard_enable_failed": "無法啟用 Destructive Command Guard:{{error}}", "share_task_failed": "分享工作失敗。請重試。", "share_no_active_task": "沒有活躍的工作可分享", diff --git a/src/services/destructive-command-guard/__tests__/manager.spec.ts b/src/services/destructive-command-guard/__tests__/manager.spec.ts index 226695aaaf..9227d90c65 100644 --- a/src/services/destructive-command-guard/__tests__/manager.spec.ts +++ b/src/services/destructive-command-guard/__tests__/manager.spec.ts @@ -1,7 +1,43 @@ -import { DCG_ARCHIVES } from "../constants" -import { getDcgArchiveInfo, getDcgBinaryPath, isDcgSupportedPlatform } from "../manager" +import { createHash } from "crypto" +import { EventEmitter } from "events" +import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises" +import { tmpdir } from "os" +import path from "path" +import { PassThrough } from "stream" + +import { spawn } from "child_process" + +import { DCG_ARCHIVES, DCG_MAX_ARCHIVE_BYTES, DCG_VERSION } from "../constants" +import { + assertArchiveSizeWithinLimit, + cleanupStaleInstallations, + downloadFile, + extractSingleBinary, + getDcgArchiveInfo, + getDcgBinaryPath, + isDcgSupportedPlatform, + isTrustedDownloadUrl, + promoteStagedInstallation, + resolveTrustedRedirect, + verifyChecksum, +} from "../manager" + +vi.mock("child_process", () => ({ spawn: vi.fn() })) + +const mockSpawn = vi.mocked(spawn) describe("Destructive Command Guard manager", () => { + let tempDir: string + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), "dcg-manager-")) + mockSpawn.mockReset() + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + }) + it("maps all supported platform and architecture combinations", () => { expect(Object.keys(DCG_ARCHIVES).sort()).toEqual([ "darwin-arm64", @@ -21,8 +57,123 @@ describe("Destructive Command Guard manager", () => { }) it("returns a versioned managed binary path", () => { - expect(getDcgBinaryPath("/storage", "linux", "x64")).toMatch( - /[/\\]destructive-command-guard[/\\]v0\.7\.7[/\\]dcg$/, + expect(getDcgBinaryPath("/storage", "linux", "x64")).toBe( + path.join("/storage", "destructive-command-guard", DCG_VERSION, "dcg"), + ) + }) + + it("accepts only HTTPS URLs on trusted host boundaries", () => { + expect(isTrustedDownloadUrl("https://github.com/release")).toBe(true) + expect(isTrustedDownloadUrl("https://cdn.objects.githubusercontent.com/release")).toBe(true) + expect(isTrustedDownloadUrl("http://github.com/release")).toBe(false) + expect(isTrustedDownloadUrl("https://evilgithub.com/release")).toBe(false) + }) + + it("rejects untrusted download URLs before opening a destination", async () => { + await expect(downloadFile("https://example.com/dcg", path.join(tempDir, "archive"))).rejects.toThrow( + "DCG download redirected to an untrusted host", + ) + }) + + it("allows trusted relative redirects and rejects unsafe or exhausted redirects", () => { + expect(resolveTrustedRedirect("https://github.com/release", "/asset", 5)).toBe("https://github.com/asset") + expect(() => resolveTrustedRedirect("https://github.com/release", "https://example.com/asset", 5)).toThrow( + "DCG download redirected to an untrusted host", + ) + expect(() => resolveTrustedRedirect("https://github.com/release", "/asset", 0)).toThrow( + "Too many DCG download redirects", + ) + expect(() => resolveTrustedRedirect("https://github.com/release", undefined, 5)).toThrow( + "Too many DCG download redirects", + ) + }) + + it("enforces the archive download size limit", () => { + expect(() => assertArchiveSizeWithinLimit(DCG_MAX_ARCHIVE_BYTES)).not.toThrow() + expect(() => assertArchiveSizeWithinLimit(DCG_MAX_ARCHIVE_BYTES + 1)).toThrow( + "DCG archive exceeds the download size limit", ) }) + + it("verifies matching checksums and rejects mismatches", async () => { + const filePath = path.join(tempDir, "archive") + const contents = Buffer.from("verified archive") + await writeFile(filePath, contents) + const checksum = createHash("sha256").update(contents).digest("hex") + + await expect(verifyChecksum(filePath, checksum)).resolves.toBeUndefined() + await expect(verifyChecksum(filePath, "0".repeat(64))).rejects.toThrow( + "DCG archive checksum verification failed", + ) + }) + + it("uses PowerShell to validate and extract a single ZIP entry", async () => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) + // The production code uses only the event and stream subset supplied by this test double. + mockSpawn.mockReturnValue(child as unknown as ReturnType) + + const extraction = extractSingleBinary("C:\\dcg.zip", "C:\\staging", DCG_ARCHIVES["win32-x64"]) + child.emit("close", 0) + await extraction + + expect(mockSpawn).toHaveBeenCalledWith( + "powershell", + expect.arrayContaining(["-NoProfile", "-NonInteractive", "-Command"]), + expect.objectContaining({ shell: false }), + ) + const script = mockSpawn.mock.calls[0][1][3] + expect(script).toContain("$entries.Count -ne 1") + expect(script).toContain("dcg.exe") + }) + + it("rejects unexpected tar archive layouts before extraction", async () => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) + // The production code uses only the event and stream subset supplied by this test double. + mockSpawn.mockReturnValue(child as unknown as ReturnType) + + const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"]) + child.stdout.write("dcg\nREADME.md\n") + child.emit("close", 0) + + await expect(extraction).rejects.toThrow("DCG archive has an unexpected layout") + expect(mockSpawn).toHaveBeenCalledTimes(1) + }) + + it("does not replace an installation completed by another process", async () => { + const stagingDir = path.join(tempDir, "staging") + const finalDir = path.join(tempDir, "final") + const binaryPath = path.join(finalDir, "dcg") + await mkdir(stagingDir) + await mkdir(finalDir) + await writeFile(path.join(stagingDir, "dcg"), "staged") + await writeFile(binaryPath, "installed") + + await promoteStagedInstallation(stagingDir, finalDir, binaryPath) + + expect(await readFile(binaryPath, "utf8")).toBe("installed") + expect(await readFile(path.join(stagingDir, "dcg"), "utf8")).toBe("staged") + }) + + it("removes only stale version directories after a successful update", async () => { + const currentDir = path.join(tempDir, DCG_VERSION) + const staleDir = path.join(tempDir, "v0.6.0") + const stagingDir = path.join(tempDir, `${DCG_VERSION}.staging-123`) + const unrelatedDir = path.join(tempDir, "user-data") + await Promise.all([currentDir, staleDir, stagingDir, unrelatedDir].map((dir) => mkdir(dir))) + + await cleanupStaleInstallations(tempDir) + + await expect(access(staleDir)).rejects.toThrow() + await expect( + Promise.all([currentDir, stagingDir, unrelatedDir].map((dir) => access(dir))), + ).resolves.toBeDefined() + }) }) diff --git a/src/services/destructive-command-guard/__tests__/runner.spec.ts b/src/services/destructive-command-guard/__tests__/runner.spec.ts new file mode 100644 index 0000000000..47eb355dc8 --- /dev/null +++ b/src/services/destructive-command-guard/__tests__/runner.spec.ts @@ -0,0 +1,126 @@ +import { EventEmitter } from "events" +import { PassThrough } from "stream" + +import { spawn } from "child_process" + +import { DCG_MAX_OUTPUT_BYTES } from "../constants" +import { runDcg } from "../runner" + +vi.mock("child_process", () => ({ spawn: vi.fn() })) + +type MockChild = EventEmitter & { + stdout: PassThrough + stderr: PassThrough + kill: ReturnType +} + +const mockSpawn = vi.mocked(spawn) + +const useChild = (child: MockChild): void => { + // runDcg uses only the event, stream, and kill subset supplied by this test double. + mockSpawn.mockReturnValue(child as unknown as ReturnType) +} + +function createChild(): MockChild { + return Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) +} + +function emitResult(child: MockChild, payload: unknown, code: number): void { + child.stdout.write(JSON.stringify(payload)) + child.emit("close", code, null) +} + +describe("runDcg", () => { + beforeEach(() => { + vi.useRealTimers() + mockSpawn.mockReset() + }) + + afterEach(() => vi.useRealTimers()) + + it.each([ + [{ schema_version: 1, decision: "allow" }, 0, { decision: "allow" }], + [ + { schema_version: 2, decision: "deny", reason: "unsafe", rule_id: "delete" }, + 1, + { decision: "deny", reason: "unsafe", ruleId: "delete" }, + ], + [ + { schema_version: 2, decision: "deny", pack_id: "core", pattern_name: "delete" }, + 1, + { decision: "deny", ruleId: "core:delete" }, + ], + ])("accepts valid DCG result %#", async (payload, code, expected) => { + const child = createChild() + useChild(child) + + const result = runDcg("/dcg", "echo test", "/workspace") + emitResult(child, payload, code) + + await expect(result).resolves.toEqual(expected) + }) + + it.each([ + ["not json", 0, "DCG returned invalid JSON"], + [JSON.stringify({ schema_version: 3, decision: "allow" }), 0, "DCG returned an unsupported response schema"], + [JSON.stringify({ schema_version: 1, decision: "deny" }), 0, "DCG decision did not match its exit status"], + ])("rejects invalid output %#", async (output, code, message) => { + const child = createChild() + useChild(child) + + const result = runDcg("/dcg", "echo test", "/workspace") + child.stdout.write(output) + child.emit("close", code, null) + + await expect(result).rejects.toThrow(message) + }) + + it("rejects non-DCG exit statuses with stderr", async () => { + const child = createChild() + useChild(child) + + const result = runDcg("/dcg", "echo test", "/workspace") + child.stderr.write("failure details") + child.emit("close", 2, null) + + await expect(result).rejects.toThrow("DCG evaluation failed: failure details") + }) + + it("rejects process startup errors", async () => { + const child = createChild() + useChild(child) + + const result = runDcg("/dcg", "echo test", "/workspace") + child.emit("error", new Error("ENOENT")) + + await expect(result).rejects.toThrow("Unable to start DCG: ENOENT") + }) + + it("rejects excessive output and kills the process", async () => { + const child = createChild() + useChild(child) + + const result = runDcg("/dcg", "echo test", "/workspace") + child.stdout.write(Buffer.alloc(DCG_MAX_OUTPUT_BYTES + 1)) + + await expect(result).rejects.toThrow("DCG produced too much output") + expect(child.kill).toHaveBeenCalledWith("SIGKILL") + }) + + it("times out and kills the process", async () => { + vi.useFakeTimers() + const child = createChild() + useChild(child) + + const result = runDcg("/dcg", "echo test", "/workspace") + const rejection = expect(result).rejects.toThrow("DCG evaluation timed out") + await vi.runAllTimersAsync() + + await rejection + expect(child.kill).toHaveBeenCalledWith("SIGKILL") + }) +}) diff --git a/src/services/destructive-command-guard/constants.ts b/src/services/destructive-command-guard/constants.ts index 6900732fda..1f854cc56a 100644 --- a/src/services/destructive-command-guard/constants.ts +++ b/src/services/destructive-command-guard/constants.ts @@ -1,10 +1,10 @@ export const DCG_VERSION = "v0.7.7" -export type DcgArchiveInfo = { +export type DcgArchiveInfo = Readonly<{ archive: string binary: "dcg" | "dcg.exe" sha256: string -} +}> export const DCG_ARCHIVES: Readonly> = { "darwin-arm64": { @@ -37,7 +37,7 @@ export const DCG_ARCHIVES: Readonly> = { binary: "dcg.exe", sha256: "435127410eabc53e772be4f5c668a875b45fbaf806654b577c2d975bd0e38964", }, -} +} as const export const DCG_DOWNLOAD_BASE_URL = `https://github.com/Dicklesworthstone/destructive_command_guard/releases/download/${DCG_VERSION}` diff --git a/src/services/destructive-command-guard/manager.ts b/src/services/destructive-command-guard/manager.ts index 24ef392cc4..447d863098 100644 --- a/src/services/destructive-command-guard/manager.ts +++ b/src/services/destructive-command-guard/manager.ts @@ -16,6 +16,7 @@ import { } from "./constants" const installationPromises = new Map>() +const VERSION_DIRECTORY_PATTERN = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/ export function getDcgArchiveInfo(platform = process.platform, arch = process.arch): DcgArchiveInfo | undefined { return DCG_ARCHIVES[`${platform}-${arch}`] @@ -34,7 +35,7 @@ export function getDcgBinaryPath( return info ? path.join(storageDir, "destructive-command-guard", DCG_VERSION, info.binary) : undefined } -function isTrustedDownloadUrl(url: string): boolean { +export function isTrustedDownloadUrl(url: string): boolean { try { const parsed = new URL(url) return ( @@ -48,7 +49,26 @@ function isTrustedDownloadUrl(url: string): boolean { } } -function downloadFile(url: string, destination: string, redirectsRemaining = 5): Promise { +export function resolveTrustedRedirect(url: string, location: string | undefined, redirectsRemaining: number): string { + if (redirectsRemaining <= 0 || !location) { + throw new Error("Too many DCG download redirects") + } + + const nextUrl = new URL(location, url).toString() + if (!isTrustedDownloadUrl(nextUrl)) { + throw new Error("DCG download redirected to an untrusted host") + } + + return nextUrl +} + +export function assertArchiveSizeWithinLimit(size: number): void { + if (size > DCG_MAX_ARCHIVE_BYTES) { + throw new Error("DCG archive exceeds the download size limit") + } +} + +export function downloadFile(url: string, destination: string, redirectsRemaining = 5): Promise { return new Promise((resolve, reject) => { if (!isTrustedDownloadUrl(url)) { reject(new Error("DCG download redirected to an untrusted host")) @@ -59,11 +79,13 @@ function downloadFile(url: string, destination: string, redirectsRemaining = 5): const status = response.statusCode ?? 0 if ([301, 302, 303, 307, 308].includes(status)) { response.resume() - if (redirectsRemaining <= 0 || !response.headers.location) { - reject(new Error("Too many DCG download redirects")) + let nextUrl: string + try { + nextUrl = resolveTrustedRedirect(url, response.headers.location, redirectsRemaining) + } catch (error) { + reject(error) return } - const nextUrl = new URL(response.headers.location, url).toString() downloadFile(nextUrl, destination, redirectsRemaining - 1).then(resolve, reject) return } @@ -75,9 +97,11 @@ function downloadFile(url: string, destination: string, redirectsRemaining = 5): } const declaredSize = Number(response.headers["content-length"] ?? 0) - if (declaredSize > DCG_MAX_ARCHIVE_BYTES) { + try { + assertArchiveSizeWithinLimit(declaredSize) + } catch (error) { response.resume() - reject(new Error("DCG archive exceeds the download size limit")) + reject(error) return } @@ -85,8 +109,10 @@ function downloadFile(url: string, destination: string, redirectsRemaining = 5): const output = createWriteStream(destination, { flags: "wx", mode: 0o600 }) response.on("data", (chunk: Buffer) => { received += chunk.length - if (received > DCG_MAX_ARCHIVE_BYTES) { - request.destroy(new Error("DCG archive exceeds the download size limit")) + try { + assertArchiveSizeWithinLimit(received) + } catch (error) { + request.destroy(error as Error) } }) response.pipe(output) @@ -99,7 +125,7 @@ function downloadFile(url: string, destination: string, redirectsRemaining = 5): }) } -async function verifyChecksum(filePath: string, expected: string): Promise { +export async function verifyChecksum(filePath: string, expected: string): Promise { const hash = createHash("sha256") await new Promise((resolve, reject) => { const input = createReadStream(filePath) @@ -142,9 +168,36 @@ function runProcess( }) } -async function extractSingleBinary(archivePath: string, stagingDir: string, info: DcgArchiveInfo): Promise { - const listingArgs = info.archive.endsWith(".zip") ? ["-tf", archivePath] : ["-tJf", archivePath] - const listing = await runProcess("tar", listingArgs) +function escapePowerShellLiteral(value: string): string { + return value.replace(/'/g, "''") +} + +async function extractZipSingleBinary(archivePath: string, stagingDir: string, info: DcgArchiveInfo): Promise { + const script = [ + "$ErrorActionPreference = 'Stop'", + "Add-Type -AssemblyName System.IO.Compression.FileSystem", + `$archive = [System.IO.Compression.ZipFile]::OpenRead('${escapePowerShellLiteral(archivePath)}')`, + "try {", + " $entries = @($archive.Entries | Where-Object { -not [string]::IsNullOrEmpty($_.Name) })", + ` if ($entries.Count -ne 1 -or $entries[0].FullName -ne '${escapePowerShellLiteral(info.binary)}') { throw 'DCG archive has an unexpected layout' }`, + ` [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entries[0], '${escapePowerShellLiteral(path.join(stagingDir, info.binary))}', $false)`, + "} finally { $archive.Dispose() }", + ].join("; ") + + await runProcess("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]) +} + +export async function extractSingleBinary( + archivePath: string, + stagingDir: string, + info: DcgArchiveInfo, +): Promise { + if (info.archive.endsWith(".zip")) { + await extractZipSingleBinary(archivePath, stagingDir, info) + return + } + + const listing = await runProcess("tar", ["-tJf", archivePath]) const entries = listing.stdout .split(/\r?\n/) .map((entry) => entry.trim().replace(/^\.\//, "")) @@ -153,10 +206,46 @@ async function extractSingleBinary(archivePath: string, stagingDir: string, info throw new Error("DCG archive has an unexpected layout") } - const extractArgs = info.archive.endsWith(".zip") - ? ["-xf", archivePath, "-C", stagingDir, info.binary] - : ["-xJf", archivePath, "-C", stagingDir, info.binary] - await runProcess("tar", extractArgs) + await runProcess("tar", ["-xJf", archivePath, "-C", stagingDir, info.binary]) +} + +export async function promoteStagedInstallation( + stagingDir: string, + finalDir: string, + binaryPath: string, +): Promise { + try { + await fs.access(binaryPath) + return + } catch { + // No other process completed this installation while this one was staged. + } + + await fs.rm(finalDir, { recursive: true, force: true }) + await fs.rename(stagingDir, finalDir) +} + +/** + * Best-effort removal of prior version directories after the current version + * has been verified and promoted. Unrelated files and staging directories are + * deliberately preserved so cleanup cannot interfere with another installer. + */ +export async function cleanupStaleInstallations(installRoot: string): Promise { + try { + const entries = await fs.readdir(installRoot, { withFileTypes: true }) + await Promise.all( + entries + .filter( + (entry) => + entry.isDirectory() && entry.name !== DCG_VERSION && VERSION_DIRECTORY_PATTERN.test(entry.name), + ) + .map((entry) => + fs.rm(path.join(installRoot, entry.name), { recursive: true, force: true }).catch(() => {}), + ), + ) + } catch { + // Cleanup is cosmetic and must never invalidate a successful installation. + } } async function installDcg(storageDir: string): Promise { @@ -170,6 +259,9 @@ async function installDcg(storageDir: string): Promise { const binaryPath = path.join(finalDir, info.binary) try { await fs.access(binaryPath) + if (process.platform !== "win32") { + await fs.chmod(binaryPath, 0o755) + } return binaryPath } catch { // First install, or the managed executable was removed. @@ -192,8 +284,8 @@ async function installDcg(storageDir: string): Promise { if (!`${version.stdout}\n${version.stderr}`.includes(DCG_VERSION.replace(/^v/, ""))) { throw new Error("Downloaded DCG executable reported an unexpected version") } - await fs.rm(finalDir, { recursive: true, force: true }) - await fs.rename(stagingDir, finalDir) + await promoteStagedInstallation(stagingDir, finalDir, binaryPath) + await cleanupStaleInstallations(installRoot) return binaryPath } finally { await fs.rm(archivePath, { force: true }).catch(() => {}) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 588af55125..3c48b2fdd1 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -293,8 +293,12 @@ export const ChatRowContent = ({ case "command": if (message.autoApprovalDecision === "deny") { return [ - , - + , + {t("chat:commandExecution.denied")} , ] diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 4d563392a7..4884d5a8b6 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -109,8 +109,8 @@ export const CommandExecution = ({ executionId, text, icon, title, isDenied = fa } const handleDenyPatternChange = (pattern: string) => { - const isDenied = deniedCommands.includes(pattern) - const newDenied = isDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern] + const isPatternDenied = deniedCommands.includes(pattern) + const newDenied = isPatternDenied ? deniedCommands.filter((p) => p !== pattern) : [...deniedCommands, pattern] const newAllowed = allowedCommands.filter((p) => p !== pattern) setAllowedCommands(newAllowed) diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 3ed71666f5..3ef6b71952 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -330,7 +330,7 @@ "description": "Erlaubte Terminal-Befehle automatisch ohne Genehmigung ausführen", "destructiveCommandGuard": { "label": "Schutz vor destruktiven Befehlen aktivieren", - "description": "Lädt Destructive Command Guard (DCG) für diese Plattform herunter und verwendet es. Von DCG erlaubte Befehle werden automatisch ausgeführt. Von DCG blockierte Befehle benötigen deine Zustimmung. Zoos Befehlslisten sind währenddessen deaktiviert. Die heruntergeladene ausführbare Datei bleibt erhalten, wenn du die Option ausschaltest." + "description": "Lädt Destructive Command Guard (DCG) für diese Plattform herunter und verwendet es. Von DCG erlaubte Befehle werden automatisch ausgeführt. Von DCG blockierte Befehle benötigen deine Zustimmung. Die Befehlslisten von Zoo sind währenddessen deaktiviert. Die heruntergeladene ausführbare Datei bleibt erhalten, wenn du die Option ausschaltest." }, "allowedCommands": "Erlaubte Auto-Ausführungsbefehle", "allowedCommandsDescription": "Befehlspräfixe, die automatisch ausgeführt werden können, wenn 'Ausführungsoperationen immer genehmigen' aktiviert ist. Fügen Sie * hinzu, um alle Befehle zu erlauben (mit Vorsicht verwenden).", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index cd5a5db804..93257e89f4 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -331,7 +331,7 @@ "description": "Exécuter automatiquement les commandes de terminal autorisées sans nécessiter d'approbation", "destructiveCommandGuard": { "label": "Activer la protection contre les commandes destructrices", - "description": "Télécharge et utilise Destructive Command Guard (DCG) pour cette plateforme. Les commandes autorisées par DCG s'exécutent automatiquement. Les commandes bloquées par DCG nécessitent ton approbation. Les listes de commandes de Zoo sont désactivées tant que cette option est active. L'exécutable téléchargé est conservé si tu la désactives." + "description": "Télécharge et utilise Destructive Command Guard (DCG) pour cette plateforme. Les commandes autorisées par DCG s'exécutent automatiquement. Les commandes bloquées par DCG nécessitent votre approbation. Les listes de commandes de Zoo sont désactivées tant que cette option est active. L'exécutable téléchargé est conservé si vous la désactivez." }, "allowedCommands": "Commandes auto-exécutables autorisées", "allowedCommandsDescription": "Préfixes de commandes qui peuvent être auto-exécutés lorsque \"Toujours approuver les opérations d'exécution\" est activé. Ajoutez * pour autoriser toutes les commandes (à utiliser avec précaution).", From 984f7c447d27cfac9e3b44ccd5f47fcc5445bf65 Mon Sep 17 00:00:00 2001 From: Naved Date: Wed, 29 Jul 2026 00:50:31 -0700 Subject: [PATCH 08/12] test(dcg): increase integration coverage --- .../__tests__/manager.spec.ts | 160 +++++++++++++++++- .../__tests__/AutoApproveSettings.spec.tsx | 15 ++ 2 files changed, 174 insertions(+), 1 deletion(-) diff --git a/src/services/destructive-command-guard/__tests__/manager.spec.ts b/src/services/destructive-command-guard/__tests__/manager.spec.ts index 9227d90c65..5e6047f7f5 100644 --- a/src/services/destructive-command-guard/__tests__/manager.spec.ts +++ b/src/services/destructive-command-guard/__tests__/manager.spec.ts @@ -1,11 +1,13 @@ import { createHash } from "crypto" import { EventEmitter } from "events" -import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "fs/promises" +import { access, chmod, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "fs/promises" import { tmpdir } from "os" import path from "path" import { PassThrough } from "stream" import { spawn } from "child_process" +import { get } from "https" +import type { IncomingMessage, RequestOptions } from "http" import { DCG_ARCHIVES, DCG_MAX_ARCHIVE_BYTES, DCG_VERSION } from "../constants" import { @@ -19,12 +21,15 @@ import { isTrustedDownloadUrl, promoteStagedInstallation, resolveTrustedRedirect, + ensureDcgInstalled, verifyChecksum, } from "../manager" vi.mock("child_process", () => ({ spawn: vi.fn() })) +vi.mock("https", () => ({ get: vi.fn() })) const mockSpawn = vi.mocked(spawn) +const mockGet = vi.mocked(get) describe("Destructive Command Guard manager", () => { let tempDir: string @@ -32,6 +37,7 @@ describe("Destructive Command Guard manager", () => { beforeEach(async () => { tempDir = await mkdtemp(path.join(tmpdir(), "dcg-manager-")) mockSpawn.mockReset() + mockGet.mockReset() }) afterEach(async () => { @@ -67,6 +73,7 @@ describe("Destructive Command Guard manager", () => { expect(isTrustedDownloadUrl("https://cdn.objects.githubusercontent.com/release")).toBe(true) expect(isTrustedDownloadUrl("http://github.com/release")).toBe(false) expect(isTrustedDownloadUrl("https://evilgithub.com/release")).toBe(false) + expect(isTrustedDownloadUrl("not a URL")).toBe(false) }) it("rejects untrusted download URLs before opening a destination", async () => { @@ -147,6 +154,47 @@ describe("Destructive Command Guard manager", () => { expect(mockSpawn).toHaveBeenCalledTimes(1) }) + it("extracts a validated tar archive containing only the managed binary", async () => { + const children = [0, 1].map(() => + Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }), + ) + mockSpawn.mockReturnValueOnce(children[0] as unknown as ReturnType) + mockSpawn.mockReturnValueOnce(children[1] as unknown as ReturnType) + + const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"]) + children[0].stdout.write("./dcg\n") + children[0].emit("close", 0) + await new Promise((resolve) => setImmediate(resolve)) + children[1].emit("close", 0) + + await extraction + expect(mockSpawn).toHaveBeenNthCalledWith( + 2, + "tar", + ["-xJf", "/tmp/dcg.tar.xz", "-C", tempDir, "dcg"], + expect.objectContaining({ shell: false }), + ) + }) + + it("surfaces process failures during extraction", async () => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) + mockSpawn.mockReturnValue(child as unknown as ReturnType) + + const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"]) + child.stderr.write("invalid archive") + child.emit("close", 2) + + await expect(extraction).rejects.toThrow("invalid archive") + }) + it("does not replace an installation completed by another process", async () => { const stagingDir = path.join(tempDir, "staging") const finalDir = path.join(tempDir, "final") @@ -162,6 +210,19 @@ describe("Destructive Command Guard manager", () => { expect(await readFile(path.join(stagingDir, "dcg"), "utf8")).toBe("staged") }) + it("promotes a staged installation when no completed installation exists", async () => { + const stagingDir = path.join(tempDir, "staging") + const finalDir = path.join(tempDir, "final") + const binaryPath = path.join(finalDir, "dcg") + await mkdir(stagingDir) + await writeFile(path.join(stagingDir, "dcg"), "staged") + + await promoteStagedInstallation(stagingDir, finalDir, binaryPath) + + expect(await readFile(binaryPath, "utf8")).toBe("staged") + await expect(access(stagingDir)).rejects.toThrow() + }) + it("removes only stale version directories after a successful update", async () => { const currentDir = path.join(tempDir, DCG_VERSION) const staleDir = path.join(tempDir, "v0.6.0") @@ -176,4 +237,101 @@ describe("Destructive Command Guard manager", () => { Promise.all([currentDir, stagingDir, unrelatedDir].map((dir) => access(dir))), ).resolves.toBeDefined() }) + + it("treats stale-installation cleanup failures as cosmetic", async () => { + await expect(cleanupStaleInstallations(path.join(tempDir, "missing"))).resolves.toBeUndefined() + }) + + it("reuses an existing managed binary and restores its executable permissions", async () => { + const binaryPath = getDcgBinaryPath(tempDir) + expect(binaryPath).toBeDefined() + await mkdir(path.dirname(binaryPath!), { recursive: true }) + await writeFile(binaryPath!, "existing binary") + if (process.platform !== "win32") { + await chmod(binaryPath!, 0o600) + } + + await expect(ensureDcgInstalled(tempDir)).resolves.toBe(binaryPath) + expect(mockSpawn).not.toHaveBeenCalled() + if (process.platform !== "win32") { + expect((await stat(binaryPath!)).mode & 0o111).toBe(0o111) + } + }) + + it("downloads, verifies, extracts, and deduplicates a new installation", async () => { + const info = getDcgArchiveInfo() + expect(info).toBeDefined() + if (!info || info.archive.endsWith(".zip")) return + + const archive = Buffer.from("test archive") + const originalChecksum = info.sha256 + Object.defineProperty(info, "sha256", { + value: createHash("sha256").update(archive).digest("hex"), + configurable: true, + }) + const now = vi.spyOn(Date, "now").mockReturnValue(1234) + + const response = Object.assign(new PassThrough(), { + statusCode: 200, + headers: { "content-length": String(archive.length) }, + }) + const request = Object.assign(new EventEmitter(), { + setTimeout: vi.fn(), + destroy: vi.fn(), + }) + mockGet.mockImplementation( + ( + _url: string | URL, + optionsOrCallback: RequestOptions | ((response: IncomingMessage) => void), + optionalCallback?: (response: IncomingMessage) => void, + ) => { + const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback + setImmediate(() => { + // The downloader uses only the response stream/status subset supplied here. + callback?.(response as unknown as IncomingMessage) + response.end(archive) + }) + // The downloader uses only timeout/error handling from ClientRequest. + return request as unknown as ReturnType + }, + ) + + mockSpawn.mockImplementation((executable, args) => { + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) + setImmediate(async () => { + if (executable === "tar" && args[0] === "-tJf") { + child.stdout.write(`${info.binary}\n`) + } else if (executable === "tar") { + const stagingDir = args[args.indexOf("-C") + 1] + await writeFile(path.join(stagingDir, info.binary), "executable") + } else { + child.stdout.write(DCG_VERSION.replace(/^v/, "")) + } + child.emit("close", 0) + }) + // The process runner uses only the event and stream subset supplied here. + return child as unknown as ReturnType + }) + + try { + const firstInstallation = ensureDcgInstalled(tempDir) + const concurrentInstallation = ensureDcgInstalled(tempDir) + expect(concurrentInstallation).toBe(firstInstallation) + + const binaryPath = await firstInstallation + expect(await readFile(binaryPath, "utf8")).toBe("executable") + expect(mockGet).toHaveBeenCalledTimes(1) + expect(mockSpawn).toHaveBeenCalledTimes(3) + await expect( + access(path.join(tempDir, "destructive-command-guard", `${info.archive}.1234.download`)), + ).rejects.toThrow() + } finally { + now.mockRestore() + Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true }) + } + }) }) diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx index b0ff054007..3590cb1af9 100644 --- a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx @@ -113,6 +113,21 @@ describe("AutoApproveSettings - Save/Discard contract", () => { expect(screen.getByTestId("destructive-command-guard-checkbox")).not.toBeChecked() }) + it("renders destructive command guard enabled from cached settings", () => { + renderSettings({ destructiveCommandGuardEnabled: true }) + + expect(screen.getByTestId("destructive-command-guard-checkbox")).toBeChecked() + }) + + it("buffers disabling destructive command guard", () => { + const { setCachedStateField } = renderSettings({ destructiveCommandGuardEnabled: true }) + + fireEvent.click(screen.getByTestId("destructive-command-guard-checkbox")) + + expect(setCachedStateField).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false) + expectNoImmediateUpdateSettings() + }) + it("hides Zoo command list editors while destructive command guard is enabled", () => { renderSettings({ destructiveCommandGuardEnabled: true, deniedCommands: ["rm -rf"] }) From ceee77b3985705ba5d8df186971145b587c0d049 Mon Sep 17 00:00:00 2001 From: Naved Date: Wed, 29 Jul 2026 01:16:55 -0700 Subject: [PATCH 09/12] test(dcg): cover settings installation paths --- .../__tests__/webviewMessageHandler.spec.ts | 61 +++++++++++++++++++ .../__tests__/AutoApproveSettings.spec.tsx | 20 ++++++ 2 files changed, 81 insertions(+) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 64ad6804df..57b5d517b4 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -26,6 +26,10 @@ vi.mock("../../../services/command/commands", () => ({ getCommands: vi.fn(), })) +vi.mock("../../../services/destructive-command-guard", () => ({ + ensureDcgInstalled: vi.fn(), +})) + vi.mock("@anthropic-ai/vertex-sdk", () => ({ AnthropicVertex: vi.fn(), })) @@ -58,6 +62,7 @@ import type { ClineProvider } from "../ClineProvider" import { flushModels, getModels } from "../../../api/providers/fetchers/modelCache" import { getLMStudioModels } from "../../../api/providers/fetchers/lmstudio" import { getCommands } from "../../../services/command/commands" +import { ensureDcgInstalled } from "../../../services/destructive-command-guard" import { handleCreateRule, handleDeleteRule, @@ -1098,6 +1103,62 @@ describe("webviewMessageHandler - mcpEnabled", () => { }) }) +describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(ensureDcgInstalled).mockResolvedValue("/mock/global/storage/dcg") + }) + + it("installs and persists destructive command guard when enabled", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { destructiveCommandGuardEnabled: true }, + }) + + expect(ensureDcgInstalled).toHaveBeenCalledWith("/mock/global/storage") + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", true) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("disables the setting and reports an installation failure", async () => { + vi.mocked(ensureDcgInstalled).mockRejectedValue(new Error("checksum mismatch")) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { destructiveCommandGuardEnabled: true }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "common:errors.destructive_command_guard_enable_failed", + ) + }) + + it("reports non-Error installation failures", async () => { + vi.mocked(ensureDcgInstalled).mockRejectedValue("download unavailable") + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { destructiveCommandGuardEnabled: true }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false) + expect(t).toHaveBeenCalledWith("common:errors.destructive_command_guard_enable_failed", { + error: "download unavailable", + }) + }) + + it("persists disabled state without trying to install", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { destructiveCommandGuardEnabled: false }, + }) + + expect(ensureDcgInstalled).not.toHaveBeenCalled() + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false) + }) +}) + describe("webviewMessageHandler - terminalProfile", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx index 3590cb1af9..0808c05531 100644 --- a/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/AutoApproveSettings.spec.tsx @@ -67,6 +67,16 @@ describe("AutoApproveSettings - Save/Discard contract", () => { expectNoImmediateUpdateSettings() }) + it("buffers an allowed command submitted with Enter", () => { + const { setCachedStateField } = renderSettings() + + const input = screen.getByTestId("command-input") + fireEvent.change(input, { target: { value: "pnpm test" } }) + fireEvent.keyDown(input, { key: "Enter" }) + + expect(setCachedStateField).toHaveBeenCalledWith("allowedCommands", ["pnpm test"]) + }) + // Case 2: allowedCommands remove it("buffers a removed allowed command without persisting before Save", () => { const { setCachedStateField } = renderSettings({ allowedCommands: ["npm test"] }) @@ -88,6 +98,16 @@ describe("AutoApproveSettings - Save/Discard contract", () => { expectNoImmediateUpdateSettings() }) + it("buffers a denied command submitted with Enter", () => { + const { setCachedStateField } = renderSettings() + + const input = screen.getByTestId("denied-command-input") + fireEvent.change(input, { target: { value: "sudo rm" } }) + fireEvent.keyDown(input, { key: "Enter" }) + + expect(setCachedStateField).toHaveBeenCalledWith("deniedCommands", ["sudo rm"]) + }) + // Case 3b: deniedCommands remove it("buffers a removed denied command without persisting before Save", () => { const { setCachedStateField } = renderSettings({ deniedCommands: ["rm -rf"] }) From 2c579dfd7b05e84bcf32f9a2befaf5e35cc76808 Mon Sep 17 00:00:00 2001 From: Naved Date: Wed, 29 Jul 2026 16:19:31 -0700 Subject: [PATCH 10/12] refactor: share managed binary installation --- src/core/tools/ExecuteCommandTool.ts | 3 + .../__tests__/executeCommandTool.spec.ts | 24 + .../__tests__/webviewMessageHandler.spec.ts | 17 + src/core/webview/webviewMessageHandler.ts | 5 +- src/eslint-suppressions.json | 5 - .../__tests__/semble-downloader.spec.ts | 42 +- .../code-index/semble/semble-downloader.ts | 412 +++--------------- .../__tests__/manager.spec.ts | 136 +----- .../destructive-command-guard/constants.ts | 12 - .../destructive-command-guard/manager.ts | 288 ++---------- .../managed-binary/__tests__/archive.spec.ts | 93 ++++ .../managed-binary/__tests__/download.spec.ts | 161 +++++++ .../managed-binary/__tests__/install.spec.ts | 88 ++++ src/services/managed-binary/archive.ts | 105 +++++ src/services/managed-binary/download.ts | 149 +++++++ src/services/managed-binary/install.ts | 131 ++++++ 16 files changed, 914 insertions(+), 757 deletions(-) create mode 100644 src/services/managed-binary/__tests__/archive.spec.ts create mode 100644 src/services/managed-binary/__tests__/download.spec.ts create mode 100644 src/services/managed-binary/__tests__/install.spec.ts create mode 100644 src/services/managed-binary/archive.ts create mode 100644 src/services/managed-binary/download.ts create mode 100644 src/services/managed-binary/install.ts diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index 41cae06c73..306557dba8 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -142,6 +142,9 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> { // 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 diff --git a/src/core/tools/__tests__/executeCommandTool.spec.ts b/src/core/tools/__tests__/executeCommandTool.spec.ts index 8ee58a98b2..82522328ae 100644 --- a/src/core/tools/__tests__/executeCommandTool.spec.ts +++ b/src/core/tools/__tests__/executeCommandTool.spec.ts @@ -308,6 +308,30 @@ describe("executeCommandTool", () => { expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled() }) + it("fails closed when DCG is unavailable for the current platform", async () => { + const provider = await mockCline.providerRef.deref() + provider.context = { globalStorageUri: { fsPath: "/test/storage" } } + provider.getState.mockResolvedValue({ + destructiveCommandGuardEnabled: true, + terminalShellIntegrationDisabled: true, + }) + mockEnsureDcgInstalled.mockResolvedValue(undefined) + + await executeCommandTool.handle(mockCline as unknown as Task, mockToolUse, { + askApproval: mockAskApproval as unknown as AskApproval, + handleError: mockHandleError as unknown as HandleError, + pushToolResult: mockPushToolResult as unknown as PushToolResult, + }) + + expect(mockHandleError).toHaveBeenCalledWith( + "executing command", + expect.objectContaining({ message: "errors.destructiveCommandGuard.unavailable" }), + ) + expect(mockRunDcg).not.toHaveBeenCalled() + expect(mockAskApproval).not.toHaveBeenCalled() + expect(executeCommandModule.executeCommandInTerminal).not.toHaveBeenCalled() + }) + it("should handle missing command parameter", async () => { // Setup mockToolUse.params.command = undefined diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 57b5d517b4..8cf1076992 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1134,6 +1134,23 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { ) }) + it("disables the setting when DCG is unavailable for the current platform", async () => { + vi.mocked(ensureDcgInstalled).mockResolvedValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { destructiveCommandGuardEnabled: true }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("destructiveCommandGuardEnabled", false) + expect(t).toHaveBeenCalledWith("common:errors.destructive_command_guard_enable_failed", { + error: "common:errors.destructiveCommandGuard.unavailable", + }) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "common:errors.destructive_command_guard_enable_failed", + ) + }) + it("reports non-Error installation failures", async () => { vi.mocked(ensureDcgInstalled).mockRejectedValue("download unavailable") diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index b8b6fb03d3..ea13cf80a6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -684,7 +684,10 @@ export const webviewMessageHandler = async ( if (message.updatedSettings.destructiveCommandGuardEnabled === true) { try { const { ensureDcgInstalled } = await import("../../services/destructive-command-guard") - await ensureDcgInstalled(provider.context.globalStorageUri.fsPath) + const binaryPath = await ensureDcgInstalled(provider.context.globalStorageUri.fsPath) + if (!binaryPath) { + throw new Error(t("common:errors.destructiveCommandGuard.unavailable")) + } } catch (error) { message.updatedSettings.destructiveCommandGuardEnabled = false vscode.window.showErrorMessage( diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 608e190d04..4141e6a259 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1499,11 +1499,6 @@ "count": 3 } }, - "services/code-index/semble/semble-downloader.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 1 - } - }, "services/code-index/shared/__tests__/validation-helpers.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 4 diff --git a/src/services/code-index/semble/__tests__/semble-downloader.spec.ts b/src/services/code-index/semble/__tests__/semble-downloader.spec.ts index 7a3eee9a70..8261bfd307 100644 --- a/src/services/code-index/semble/__tests__/semble-downloader.spec.ts +++ b/src/services/code-index/semble/__tests__/semble-downloader.spec.ts @@ -263,12 +263,14 @@ describe("semble-downloader", () => { ) // Version file should be written expect(fs.writeFile).toHaveBeenCalledWith( - path.join("/storage", "semble", ".semble-version"), + path.join("/storage", "semble.new", ".semble-version"), "v0.4.1", "utf-8", ) // Archive should be cleaned up (version-prefixed local cache path) - expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "v0.4.1-semble-linux-x64-fast.tar.gz")) + expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "v0.4.1-semble-linux-x64-fast.tar.gz"), { + force: true, + }) } finally { if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform) if (originalArch) Object.defineProperty(process, "arch", originalArch) @@ -325,7 +327,9 @@ describe("semble-downloader", () => { try { await expect(downloadSemble("/storage")).rejects.toThrow("Failed to download semble") - expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "v0.4.1-semble-linux-arm64-fast.tar.gz")) + expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "v0.4.1-semble-linux-arm64-fast.tar.gz"), { + force: true, + }) // Should clean up staging directory, not the original expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "semble.new"), { recursive: true, @@ -589,7 +593,7 @@ describe("semble-downloader", () => { }) // Archive cleanup fails but should not throw (only archive removal after extraction) - ;(fs.unlink as any).mockRejectedValue(new Error("unlink cleanup failed")) + ;(fs.rm as any).mockRejectedValueOnce(new Error("archive cleanup failed")) try { const result = await downloadSemble("/storage") @@ -641,7 +645,7 @@ describe("semble-downloader", () => { expect(https.get).toHaveBeenCalledWith(expect.stringContaining("v0.4.1"), expect.any(Function)) // Should write the new version file expect(fs.writeFile).toHaveBeenCalledWith( - path.join("/storage", "semble", ".semble-version"), + path.join("/storage", "semble.new", ".semble-version"), "v0.4.1", "utf-8", ) @@ -700,19 +704,23 @@ describe("semble-downloader", () => { ) // The stale archive is removed before the fresh download to guarantee // a clean package is verified against the new checksum. - expect(fs.unlink).toHaveBeenCalledWith(versionedArchive) + expect(fs.rm).toHaveBeenCalledWith(versionedArchive, { force: true }) // The prior-version archive (v0.4.0-*) is swept by cleanupStaleArchives // after a successful install, so a version upgrade doesn't accumulate // orphaned packages on disk. - expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "v0.4.0-semble-linux-x64-fast.tar.gz")) + expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "v0.4.0-semble-linux-x64-fast.tar.gz"), { + force: true, + }) // The legacy unversioned archive (pre-v0.4.0 cache layout) is also // swept, covering the v0.3.1 → v0.4.1 upgrade path. - expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz")) + expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz"), { + force: true, + }) // Unrelated files in the storage dir must not be touched. expect(fs.unlink).not.toHaveBeenCalledWith(path.join("/storage", "unrelated-file.txt")) // The new version file is recorded expect(fs.writeFile).toHaveBeenCalledWith( - path.join("/storage", "semble", ".semble-version"), + path.join("/storage", "semble.new", ".semble-version"), "v0.4.1", "utf-8", ) @@ -789,7 +797,7 @@ describe("semble-downloader", () => { ) // Should write version file again expect(fs.writeFile).toHaveBeenCalledWith( - path.join("/storage", "semble", ".semble-version"), + path.join("/storage", "semble.new", ".semble-version"), "v0.4.1", "utf-8", ) @@ -831,7 +839,7 @@ describe("semble-downloader", () => { ) // Should write version file expect(fs.writeFile).toHaveBeenCalledWith( - path.join("/storage", "semble", ".semble-version"), + path.join("/storage", "semble.new", ".semble-version"), "v0.4.1", "utf-8", ) @@ -903,8 +911,12 @@ describe("semble-downloader", () => { const currentArchive = path.join("/storage", "v0.4.1-semble-linux-x64-fast.tar.gz") // Stale versioned + legacy unversioned archives are swept - expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "v0.4.0-semble-linux-x64-fast.tar.gz")) - expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz")) + expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "v0.4.0-semble-linux-x64-fast.tar.gz"), { + force: true, + }) + expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz"), { + force: true, + }) // The current archive is never swept by cleanupStaleArchives (it is // excluded by the currentArchivePath guard). It is unlinked only by // the pre-download partial-archive cleanup and the post-install @@ -913,8 +925,8 @@ describe("semble-downloader", () => { // Sanity: the current archive path is never passed to the stale sweep. // It is unlinked exactly twice (pre-download cleanup + post-install // archive cleanup), never via cleanupStaleArchives. - const currentUnlinks = (fs.unlink as any).mock.calls.filter((c: any[]) => c[0] === currentArchive) - expect(currentUnlinks.length).toBe(2) + const currentRemovals = (fs.rm as any).mock.calls.filter((c: any[]) => c[0] === currentArchive) + expect(currentRemovals.length).toBe(2) } finally { if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform) if (originalArch) Object.defineProperty(process, "arch", originalArch) diff --git a/src/services/code-index/semble/semble-downloader.ts b/src/services/code-index/semble/semble-downloader.ts index fc8a8e2a33..836e1946e1 100644 --- a/src/services/code-index/semble/semble-downloader.ts +++ b/src/services/code-index/semble/semble-downloader.ts @@ -1,10 +1,9 @@ import * as fs from "fs/promises" import * as path from "path" -import * as https from "https" -import { createWriteStream } from "fs" -import { createHash } from "crypto" -import { createReadStream } from "fs" -import { spawn } from "child_process" + +import { extractTarGzArchive, extractZipArchive } from "../../managed-binary/archive" +import { downloadBinaryFile, verifySha256Checksum } from "../../managed-binary/download" +import { ensureManagedBinaryInstalled, getManagedBinaryPaths } from "../../managed-binary/install" /** * Supported platform/arch combinations for the semble standalone executable. @@ -47,19 +46,14 @@ export const SEMBLE_SHA256: Record = { * Throws if the checksum does not match. */ export async function verifyChecksum(filePath: string, expected: string): Promise { - const hash = createHash("sha256") - await new Promise((resolve, reject) => { - const stream = createReadStream(filePath) - stream.on("data", (chunk) => hash.update(chunk)) - stream.on("end", resolve) - stream.on("error", reject) - }) - const actual = hash.digest("hex") - if (actual !== expected) { - throw new Error( - `Checksum mismatch for ${path.basename(filePath)}: expected ${expected.slice(0, 12)}…, got ${actual.slice(0, 12)}…`, - ) - } + await verifySha256Checksum( + filePath, + expected, + (actual) => + new Error( + `Checksum mismatch for ${path.basename(filePath)}: expected ${expected.slice(0, 12)}…, got ${actual.slice(0, 12)}…`, + ), + ) } /** @@ -87,64 +81,6 @@ function getArchiveInfo(platform?: string, arch?: string): { archive: string; bi return SEMBLE_ARCHIVES[`${p}-${a}`] } -/** - * Reads the locally installed version from the version metadata file. - * Returns undefined if no version file exists (first install or legacy). - */ -async function getInstalledVersion(storageDir: string): Promise { - try { - const versionPath = path.join(storageDir, "semble", VERSION_FILE) - const version = (await fs.readFile(versionPath, "utf-8")).trim() - return version || undefined - } catch { - return undefined - } -} - -/** - * Writes the version metadata file after a successful download. - */ -async function writeInstalledVersion(storageDir: string, version: string): Promise { - const versionPath = path.join(storageDir, "semble", VERSION_FILE) - await fs.writeFile(versionPath, version, "utf-8") -} - -/** - * Best-effort removal of archive files left over from previous semble versions. - * - * Because the local archive cache path is version-prefixed (see `downloadSemble`), - * upgrading SEMBLE_VERSION leaves the prior version's archive orphaned on disk. - * This sweeps those stale packages so a version upgrade doesn't accumulate them. - * - * Matches both the version-prefixed cache names (`${version}-${archiveName}`, - * used since v0.4.0) and the legacy unversioned cache name (`${archiveName}`, - * used before v0.4.0), so a v0.3.1 → v0.4.1 upgrade also clears the legacy file. - * The current archive path is always preserved. - * - * Errors are swallowed since this is purely cosmetic cleanup. - */ -async function cleanupStaleArchives( - storageDir: string, - archiveName: string, - currentArchivePath: string, -): Promise { - try { - const entries = await fs.readdir(storageDir) - const suffix = `-${archiveName}` - await Promise.all( - entries - .filter( - (name) => - (name === archiveName || name.endsWith(suffix)) && - path.join(storageDir, name) !== currentArchivePath, - ) - .map((name) => fs.unlink(path.join(storageDir, name)).catch(() => {})), - ) - } catch { - // ignore — storage dir may not be listable yet - } -} - /** * Downloads and extracts the semble archive for the current platform. * @@ -164,130 +100,48 @@ export async function downloadSemble(storageDir: string): Promise + downloadBinaryFile(url, archivePath, { + name: "Semble", + trustedDomains: TRUSTED_DOWNLOAD_DOMAINS, + timeoutMs: 120_000, + }), + verifyArchive: (archivePath) => verifyChecksum(archivePath, expectedChecksum), + extractArchive: async (archivePath, stagingDir) => { + if (info.archive.endsWith(".tar.gz")) { + await extractTarGzArchive(archivePath, stagingDir) + } else if (info.archive.endsWith(".zip")) { + await extractZipArchive(archivePath, stagingDir) + } + }, + }) + + console.log(`[SembleDownloader] Successfully installed semble ${SEMBLE_VERSION} to ${paths.binaryPath}`) + return result } /** @@ -309,174 +163,8 @@ export async function getSembleBinaryPath(storageDir: string): Promise { - return new Promise((resolve, reject) => { - const args = ["-xzf", archivePath, "-C", destDir, "--no-same-owner"] - // GNU tar: --no-overwrite-dir adds defense-in-depth against ../relative traversal. - // macOS bsdtar strips absolute paths by default. - if (process.platform === "linux") { - args.push("--no-overwrite-dir") - } - const child = spawn("tar", args, { - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }) - - let stderr = "" - child.stderr?.on("data", (data: Buffer) => { - stderr += data.toString() - }) - - child.on("error", (err) => reject(err)) - child.on("close", (code) => { - if (code === 0) { - resolve() - } else { - reject(new Error(`tar extraction failed (code ${code}): ${stderr.trim()}`)) - } - }) - }) -} - -/** - * Escapes a string for use inside a PowerShell single-quoted literal. - * In PowerShell, the only special character in a single-quoted string is the - * apostrophe itself, which is escaped by doubling it. - */ -function escapePowerShellLiteral(value: string): string { - return value.replace(/'/g, "''") -} - -/** - * Extracts a .zip archive into the destination directory. - * Uses PowerShell on Windows, unzip on other platforms. - */ -function extractZip(archivePath: string, destDir: string): Promise { - return new Promise((resolve, reject) => { - let child - - if (process.platform === "win32") { - child = spawn( - "powershell", - [ - "-NoProfile", - "-Command", - `Expand-Archive -Path '${escapePowerShellLiteral(archivePath)}' -DestinationPath '${escapePowerShellLiteral(destDir)}' -Force`, - ], - { shell: false, stdio: ["ignore", "pipe", "pipe"] }, - ) - } else { - child = spawn("unzip", ["-o", archivePath, "-d", destDir], { - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }) - } - - let stderr = "" - child.stderr?.on("data", (data: Buffer) => { - stderr += data.toString() - }) - - child.on("error", (err) => reject(err)) - child.on("close", (code) => { - if (code === 0) { - resolve() - } else { - reject(new Error(`zip extraction failed (code ${code}): ${stderr.trim()}`)) - } - }) - }) -} - /** * Trusted domains for following redirects during semble binary download. * GitHub releases redirect to objects.githubusercontent.com for the actual download. */ const TRUSTED_DOWNLOAD_DOMAINS = ["github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"] - -/** - * Validates that a URL belongs to a trusted domain. - * Uses domain-boundary aware matching to prevent suffix-based bypasses - * (e.g. "evilgithub.com" does NOT match "github.com"). - */ -function isTrustedDownloadUrl(url: string): boolean { - try { - const parsed = new URL(url) - const h = parsed.hostname - return parsed.protocol === "https:" && TRUSTED_DOWNLOAD_DOMAINS.some((d) => h === d || h.endsWith("." + d)) - } catch { - return false - } -} - -/** - * Downloads a file from the given URL to the destination path. - * Follows redirects (GitHub releases use 302 redirects to CDN). - * Only follows redirects to trusted domains to prevent redirect-based attacks. - */ -function downloadFile(url: string, destPath: string, maxRedirects = 5): Promise { - return new Promise((resolve, reject) => { - if (maxRedirects <= 0) { - reject(new Error("Too many redirects")) - return - } - - const request = https.get(url, (response) => { - // Follow redirects - if ( - response.statusCode && - response.statusCode >= 300 && - response.statusCode < 400 && - response.headers.location - ) { - response.destroy() - const redirectUrl = response.headers.location - if (!isTrustedDownloadUrl(redirectUrl)) { - reject( - new Error( - `Redirect to untrusted domain blocked: ${redirectUrl}. Only ${TRUSTED_DOWNLOAD_DOMAINS.join(", ")} are allowed.`, - ), - ) - return - } - downloadFile(redirectUrl, destPath, maxRedirects - 1) - .then(resolve) - .catch(reject) - return - } - - if (response.statusCode !== 200) { - response.destroy() - reject(new Error(`HTTP ${response.statusCode}: Failed to download ${url}`)) - return - } - - const file = createWriteStream(destPath) - response.pipe(file) - - file.on("finish", () => { - file.close() - resolve() - }) - - file.on("error", (err) => { - file.close() - reject(err) - }) - }) - - request.on("error", reject) - request.on("timeout", () => { - request.destroy() - reject(new Error("Download timed out")) - }) - - // 2 minute timeout for download - request.setTimeout(120_000) - }) -} diff --git a/src/services/destructive-command-guard/__tests__/manager.spec.ts b/src/services/destructive-command-guard/__tests__/manager.spec.ts index 5e6047f7f5..36e6b06722 100644 --- a/src/services/destructive-command-guard/__tests__/manager.spec.ts +++ b/src/services/destructive-command-guard/__tests__/manager.spec.ts @@ -9,17 +9,14 @@ import { spawn } from "child_process" import { get } from "https" import type { IncomingMessage, RequestOptions } from "http" -import { DCG_ARCHIVES, DCG_MAX_ARCHIVE_BYTES, DCG_VERSION } from "../constants" +import { DCG_ARCHIVES, DCG_VERSION } from "../constants" import { - assertArchiveSizeWithinLimit, - cleanupStaleInstallations, downloadFile, extractSingleBinary, getDcgArchiveInfo, getDcgBinaryPath, isDcgSupportedPlatform, isTrustedDownloadUrl, - promoteStagedInstallation, resolveTrustedRedirect, ensureDcgInstalled, verifyChecksum, @@ -45,14 +42,7 @@ describe("Destructive Command Guard manager", () => { }) it("maps all supported platform and architecture combinations", () => { - expect(Object.keys(DCG_ARCHIVES).sort()).toEqual([ - "darwin-arm64", - "darwin-x64", - "linux-arm64", - "linux-x64", - "win32-arm64", - "win32-x64", - ]) + expect(Object.keys(DCG_ARCHIVES).sort()).toEqual(["darwin-arm64", "linux-arm64", "linux-x64", "win32-x64"]) expect(getDcgArchiveInfo("darwin", "arm64")?.archive).toBe("dcg-aarch64-apple-darwin.tar.xz") expect(getDcgArchiveInfo("win32", "x64")?.binary).toBe("dcg.exe") }) @@ -62,9 +52,9 @@ describe("Destructive Command Guard manager", () => { expect(getDcgBinaryPath("/storage", "freebsd", "x64")).toBeUndefined() }) - it("returns a versioned managed binary path", () => { + it("returns the managed binary path", () => { expect(getDcgBinaryPath("/storage", "linux", "x64")).toBe( - path.join("/storage", "destructive-command-guard", DCG_VERSION, "dcg"), + path.join("/storage", "destructive-command-guard", "dcg"), ) }) @@ -95,13 +85,6 @@ describe("Destructive Command Guard manager", () => { ) }) - it("enforces the archive download size limit", () => { - expect(() => assertArchiveSizeWithinLimit(DCG_MAX_ARCHIVE_BYTES)).not.toThrow() - expect(() => assertArchiveSizeWithinLimit(DCG_MAX_ARCHIVE_BYTES + 1)).toThrow( - "DCG archive exceeds the download size limit", - ) - }) - it("verifies matching checksums and rejects mismatches", async () => { const filePath = path.join(tempDir, "archive") const contents = Buffer.from("verified archive") @@ -114,7 +97,7 @@ describe("Destructive Command Guard manager", () => { ) }) - it("uses PowerShell to validate and extract a single ZIP entry", async () => { + it("uses the platform ZIP extractor", async () => { const child = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stderr: new PassThrough(), @@ -127,17 +110,13 @@ describe("Destructive Command Guard manager", () => { child.emit("close", 0) await extraction - expect(mockSpawn).toHaveBeenCalledWith( - "powershell", - expect.arrayContaining(["-NoProfile", "-NonInteractive", "-Command"]), - expect.objectContaining({ shell: false }), - ) - const script = mockSpawn.mock.calls[0][1][3] - expect(script).toContain("$entries.Count -ne 1") - expect(script).toContain("dcg.exe") + expect(mockSpawn).toHaveBeenCalledWith("unzip", ["-o", "C:\\dcg.zip", "-d", "C:\\staging"], { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }) }) - it("rejects unexpected tar archive layouts before extraction", async () => { + it("extracts tar archives without imposing a single-file layout", async () => { const child = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stderr: new PassThrough(), @@ -147,35 +126,13 @@ describe("Destructive Command Guard manager", () => { mockSpawn.mockReturnValue(child as unknown as ReturnType) const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"]) - child.stdout.write("dcg\nREADME.md\n") child.emit("close", 0) - await expect(extraction).rejects.toThrow("DCG archive has an unexpected layout") + await expect(extraction).resolves.toBeUndefined() expect(mockSpawn).toHaveBeenCalledTimes(1) - }) - - it("extracts a validated tar archive containing only the managed binary", async () => { - const children = [0, 1].map(() => - Object.assign(new EventEmitter(), { - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn(), - }), - ) - mockSpawn.mockReturnValueOnce(children[0] as unknown as ReturnType) - mockSpawn.mockReturnValueOnce(children[1] as unknown as ReturnType) - - const extraction = extractSingleBinary("/tmp/dcg.tar.xz", tempDir, DCG_ARCHIVES["linux-x64"]) - children[0].stdout.write("./dcg\n") - children[0].emit("close", 0) - await new Promise((resolve) => setImmediate(resolve)) - children[1].emit("close", 0) - - await extraction - expect(mockSpawn).toHaveBeenNthCalledWith( - 2, + expect(mockSpawn).toHaveBeenCalledWith( "tar", - ["-xJf", "/tmp/dcg.tar.xz", "-C", tempDir, "dcg"], + expect.arrayContaining(["-xJf", "/tmp/dcg.tar.xz", "-C", tempDir, "--no-same-owner"]), expect.objectContaining({ shell: false }), ) }) @@ -195,58 +152,12 @@ describe("Destructive Command Guard manager", () => { await expect(extraction).rejects.toThrow("invalid archive") }) - it("does not replace an installation completed by another process", async () => { - const stagingDir = path.join(tempDir, "staging") - const finalDir = path.join(tempDir, "final") - const binaryPath = path.join(finalDir, "dcg") - await mkdir(stagingDir) - await mkdir(finalDir) - await writeFile(path.join(stagingDir, "dcg"), "staged") - await writeFile(binaryPath, "installed") - - await promoteStagedInstallation(stagingDir, finalDir, binaryPath) - - expect(await readFile(binaryPath, "utf8")).toBe("installed") - expect(await readFile(path.join(stagingDir, "dcg"), "utf8")).toBe("staged") - }) - - it("promotes a staged installation when no completed installation exists", async () => { - const stagingDir = path.join(tempDir, "staging") - const finalDir = path.join(tempDir, "final") - const binaryPath = path.join(finalDir, "dcg") - await mkdir(stagingDir) - await writeFile(path.join(stagingDir, "dcg"), "staged") - - await promoteStagedInstallation(stagingDir, finalDir, binaryPath) - - expect(await readFile(binaryPath, "utf8")).toBe("staged") - await expect(access(stagingDir)).rejects.toThrow() - }) - - it("removes only stale version directories after a successful update", async () => { - const currentDir = path.join(tempDir, DCG_VERSION) - const staleDir = path.join(tempDir, "v0.6.0") - const stagingDir = path.join(tempDir, `${DCG_VERSION}.staging-123`) - const unrelatedDir = path.join(tempDir, "user-data") - await Promise.all([currentDir, staleDir, stagingDir, unrelatedDir].map((dir) => mkdir(dir))) - - await cleanupStaleInstallations(tempDir) - - await expect(access(staleDir)).rejects.toThrow() - await expect( - Promise.all([currentDir, stagingDir, unrelatedDir].map((dir) => access(dir))), - ).resolves.toBeDefined() - }) - - it("treats stale-installation cleanup failures as cosmetic", async () => { - await expect(cleanupStaleInstallations(path.join(tempDir, "missing"))).resolves.toBeUndefined() - }) - it("reuses an existing managed binary and restores its executable permissions", async () => { const binaryPath = getDcgBinaryPath(tempDir) expect(binaryPath).toBeDefined() await mkdir(path.dirname(binaryPath!), { recursive: true }) await writeFile(binaryPath!, "existing binary") + await writeFile(path.join(path.dirname(binaryPath!), ".dcg-version"), DCG_VERSION) if (process.platform !== "win32") { await chmod(binaryPath!, 0o600) } @@ -269,8 +180,6 @@ describe("Destructive Command Guard manager", () => { value: createHash("sha256").update(archive).digest("hex"), configurable: true, }) - const now = vi.spyOn(Date, "now").mockReturnValue(1234) - const response = Object.assign(new PassThrough(), { statusCode: 200, headers: { "content-length": String(archive.length) }, @@ -303,13 +212,9 @@ describe("Destructive Command Guard manager", () => { kill: vi.fn(), }) setImmediate(async () => { - if (executable === "tar" && args[0] === "-tJf") { - child.stdout.write(`${info.binary}\n`) - } else if (executable === "tar") { + if (executable === "tar") { const stagingDir = args[args.indexOf("-C") + 1] await writeFile(path.join(stagingDir, info.binary), "executable") - } else { - child.stdout.write(DCG_VERSION.replace(/^v/, "")) } child.emit("close", 0) }) @@ -323,14 +228,15 @@ describe("Destructive Command Guard manager", () => { expect(concurrentInstallation).toBe(firstInstallation) const binaryPath = await firstInstallation + if (!binaryPath) throw new Error("Expected DCG to be supported in this test") expect(await readFile(binaryPath, "utf8")).toBe("executable") expect(mockGet).toHaveBeenCalledTimes(1) - expect(mockSpawn).toHaveBeenCalledTimes(3) - await expect( - access(path.join(tempDir, "destructive-command-guard", `${info.archive}.1234.download`)), - ).rejects.toThrow() + expect(mockSpawn).toHaveBeenCalledTimes(1) + await expect(access(path.join(tempDir, `${DCG_VERSION}-${info.archive}`))).rejects.toThrow() + expect(await readFile(path.join(tempDir, "destructive-command-guard", ".dcg-version"), "utf8")).toBe( + DCG_VERSION, + ) } finally { - now.mockRestore() Object.defineProperty(info, "sha256", { value: originalChecksum, configurable: true }) } }) diff --git a/src/services/destructive-command-guard/constants.ts b/src/services/destructive-command-guard/constants.ts index 1f854cc56a..74ce76ada4 100644 --- a/src/services/destructive-command-guard/constants.ts +++ b/src/services/destructive-command-guard/constants.ts @@ -12,11 +12,6 @@ export const DCG_ARCHIVES: Readonly> = { binary: "dcg", sha256: "a63cf82bd3584055112d5ec7a4ab3d7e0619a9f806a53930c27aa0e6297484de", }, - "darwin-x64": { - archive: "dcg-x86_64-apple-darwin.tar.xz", - binary: "dcg", - sha256: "15b42fbbbeab47123899e6328d90cd593e14999f3d275f71294815ad8ed9479c", - }, "linux-arm64": { archive: "dcg-aarch64-unknown-linux-gnu.tar.xz", binary: "dcg", @@ -27,11 +22,6 @@ export const DCG_ARCHIVES: Readonly> = { binary: "dcg", sha256: "472b130a9b235edc57e6cb7566641da5fef905e9dbefd3a46f9ad1e33205fa04", }, - "win32-arm64": { - archive: "dcg-aarch64-pc-windows-msvc.zip", - binary: "dcg.exe", - sha256: "93d4c71860086db00bea3aa957051cef38eac572422fa40b2a045c8cd578c3b5", - }, "win32-x64": { archive: "dcg-x86_64-pc-windows-msvc.zip", binary: "dcg.exe", @@ -41,8 +31,6 @@ export const DCG_ARCHIVES: Readonly> = { export const DCG_DOWNLOAD_BASE_URL = `https://github.com/Dicklesworthstone/destructive_command_guard/releases/download/${DCG_VERSION}` -export const DCG_MAX_ARCHIVE_BYTES = 16 * 1024 * 1024 -export const DCG_DOWNLOAD_TIMEOUT_MS = 60_000 export const DCG_RUN_TIMEOUT_MS = 3_000 export const DCG_MAX_OUTPUT_BYTES = 256 * 1024 diff --git a/src/services/destructive-command-guard/manager.ts b/src/services/destructive-command-guard/manager.ts index 447d863098..61325954bb 100644 --- a/src/services/destructive-command-guard/manager.ts +++ b/src/services/destructive-command-guard/manager.ts @@ -1,22 +1,23 @@ -import { createHash } from "crypto" -import { spawn } from "child_process" -import { createReadStream, createWriteStream } from "fs" -import * as fs from "fs/promises" -import * as https from "https" import * as path from "path" +import { extractTarXzArchive, extractZipArchive } from "../managed-binary/archive" +import { + downloadBinaryFile, + isTrustedHttpsUrl, + resolveTrustedRedirect as resolveManagedBinaryRedirect, + verifySha256Checksum, +} from "../managed-binary/download" +import { ensureManagedBinaryInstalled, getManagedBinaryPaths } from "../managed-binary/install" + import { DCG_ARCHIVES, DCG_DOWNLOAD_BASE_URL, - DCG_DOWNLOAD_TIMEOUT_MS, - DCG_MAX_ARCHIVE_BYTES, DCG_TRUSTED_DOWNLOAD_DOMAINS, DCG_VERSION, type DcgArchiveInfo, } from "./constants" -const installationPromises = new Map>() -const VERSION_DIRECTORY_PATTERN = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/ +const VERSION_FILE = ".dcg-version" export function getDcgArchiveInfo(platform = process.platform, arch = process.arch): DcgArchiveInfo | undefined { return DCG_ARCHIVES[`${platform}-${arch}`] @@ -32,159 +33,31 @@ export function getDcgBinaryPath( arch = process.arch, ): string | undefined { const info = getDcgArchiveInfo(platform, arch) - return info ? path.join(storageDir, "destructive-command-guard", DCG_VERSION, info.binary) : undefined + return info ? path.join(storageDir, "destructive-command-guard", info.binary) : undefined } export function isTrustedDownloadUrl(url: string): boolean { - try { - const parsed = new URL(url) - return ( - parsed.protocol === "https:" && - DCG_TRUSTED_DOWNLOAD_DOMAINS.some( - (domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`), - ) - ) - } catch { - return false - } + return isTrustedHttpsUrl(url, DCG_TRUSTED_DOWNLOAD_DOMAINS) } export function resolveTrustedRedirect(url: string, location: string | undefined, redirectsRemaining: number): string { - if (redirectsRemaining <= 0 || !location) { - throw new Error("Too many DCG download redirects") - } - - const nextUrl = new URL(location, url).toString() - if (!isTrustedDownloadUrl(nextUrl)) { - throw new Error("DCG download redirected to an untrusted host") - } - - return nextUrl -} - -export function assertArchiveSizeWithinLimit(size: number): void { - if (size > DCG_MAX_ARCHIVE_BYTES) { - throw new Error("DCG archive exceeds the download size limit") - } -} - -export function downloadFile(url: string, destination: string, redirectsRemaining = 5): Promise { - return new Promise((resolve, reject) => { - if (!isTrustedDownloadUrl(url)) { - reject(new Error("DCG download redirected to an untrusted host")) - return - } - - const request = https.get(url, (response) => { - const status = response.statusCode ?? 0 - if ([301, 302, 303, 307, 308].includes(status)) { - response.resume() - let nextUrl: string - try { - nextUrl = resolveTrustedRedirect(url, response.headers.location, redirectsRemaining) - } catch (error) { - reject(error) - return - } - downloadFile(nextUrl, destination, redirectsRemaining - 1).then(resolve, reject) - return - } - - if (status !== 200) { - response.resume() - reject(new Error(`DCG download failed with HTTP ${status}`)) - return - } - - const declaredSize = Number(response.headers["content-length"] ?? 0) - try { - assertArchiveSizeWithinLimit(declaredSize) - } catch (error) { - response.resume() - reject(error) - return - } - - let received = 0 - const output = createWriteStream(destination, { flags: "wx", mode: 0o600 }) - response.on("data", (chunk: Buffer) => { - received += chunk.length - try { - assertArchiveSizeWithinLimit(received) - } catch (error) { - request.destroy(error as Error) - } - }) - response.pipe(output) - output.on("finish", () => output.close(() => resolve())) - output.on("error", reject) - }) - - request.setTimeout(DCG_DOWNLOAD_TIMEOUT_MS, () => request.destroy(new Error("DCG download timed out"))) - request.on("error", reject) + return resolveManagedBinaryRedirect(url, location, redirectsRemaining, { + name: "DCG", + trustedDomains: DCG_TRUSTED_DOWNLOAD_DOMAINS, }) } -export async function verifyChecksum(filePath: string, expected: string): Promise { - const hash = createHash("sha256") - await new Promise((resolve, reject) => { - const input = createReadStream(filePath) - input.on("data", (chunk) => hash.update(chunk)) - input.on("end", resolve) - input.on("error", reject) - }) - if (hash.digest("hex") !== expected) { - throw new Error("DCG archive checksum verification failed") - } -} - -function runProcess( - executable: string, - args: string[], - timeoutMs = 30_000, -): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(executable, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] }) - let stdout = "" - let stderr = "" - const timer = setTimeout(() => { - child.kill("SIGKILL") - reject(new Error(`${path.basename(executable)} timed out`)) - }, timeoutMs) - child.stdout?.on("data", (chunk: Buffer) => (stdout += chunk.toString())) - child.stderr?.on("data", (chunk: Buffer) => (stderr += chunk.toString())) - child.on("error", (error) => { - clearTimeout(timer) - reject(error) - }) - child.on("close", (code) => { - clearTimeout(timer) - if (code === 0) { - resolve({ stdout, stderr }) - } else { - reject(new Error(stderr.trim() || `Process exited with code ${code}`)) - } - }) +export function downloadFile(url: string, destination: string, maxRedirects = 5): Promise { + return downloadBinaryFile(url, destination, { + name: "DCG", + trustedDomains: DCG_TRUSTED_DOWNLOAD_DOMAINS, + timeoutMs: 120_000, + maxRedirects, }) } -function escapePowerShellLiteral(value: string): string { - return value.replace(/'/g, "''") -} - -async function extractZipSingleBinary(archivePath: string, stagingDir: string, info: DcgArchiveInfo): Promise { - const script = [ - "$ErrorActionPreference = 'Stop'", - "Add-Type -AssemblyName System.IO.Compression.FileSystem", - `$archive = [System.IO.Compression.ZipFile]::OpenRead('${escapePowerShellLiteral(archivePath)}')`, - "try {", - " $entries = @($archive.Entries | Where-Object { -not [string]::IsNullOrEmpty($_.Name) })", - ` if ($entries.Count -ne 1 -or $entries[0].FullName -ne '${escapePowerShellLiteral(info.binary)}') { throw 'DCG archive has an unexpected layout' }`, - ` [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entries[0], '${escapePowerShellLiteral(path.join(stagingDir, info.binary))}', $false)`, - "} finally { $archive.Dispose() }", - ].join("; ") - - await runProcess("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]) +export async function verifyChecksum(filePath: string, expected: string): Promise { + await verifySha256Checksum(filePath, expected, () => new Error("DCG archive checksum verification failed")) } export async function extractSingleBinary( @@ -193,112 +66,33 @@ export async function extractSingleBinary( info: DcgArchiveInfo, ): Promise { if (info.archive.endsWith(".zip")) { - await extractZipSingleBinary(archivePath, stagingDir, info) - return - } - - const listing = await runProcess("tar", ["-tJf", archivePath]) - const entries = listing.stdout - .split(/\r?\n/) - .map((entry) => entry.trim().replace(/^\.\//, "")) - .filter(Boolean) - if (entries.length !== 1 || entries[0] !== info.binary) { - throw new Error("DCG archive has an unexpected layout") - } - - await runProcess("tar", ["-xJf", archivePath, "-C", stagingDir, info.binary]) -} - -export async function promoteStagedInstallation( - stagingDir: string, - finalDir: string, - binaryPath: string, -): Promise { - try { - await fs.access(binaryPath) + await extractZipArchive(archivePath, stagingDir) return - } catch { - // No other process completed this installation while this one was staged. } - await fs.rm(finalDir, { recursive: true, force: true }) - await fs.rename(stagingDir, finalDir) -} - -/** - * Best-effort removal of prior version directories after the current version - * has been verified and promoted. Unrelated files and staging directories are - * deliberately preserved so cleanup cannot interfere with another installer. - */ -export async function cleanupStaleInstallations(installRoot: string): Promise { - try { - const entries = await fs.readdir(installRoot, { withFileTypes: true }) - await Promise.all( - entries - .filter( - (entry) => - entry.isDirectory() && entry.name !== DCG_VERSION && VERSION_DIRECTORY_PATTERN.test(entry.name), - ) - .map((entry) => - fs.rm(path.join(installRoot, entry.name), { recursive: true, force: true }).catch(() => {}), - ), - ) - } catch { - // Cleanup is cosmetic and must never invalidate a successful installation. - } + await extractTarXzArchive(archivePath, stagingDir) } -async function installDcg(storageDir: string): Promise { +function installDcg(storageDir: string): Promise { const info = getDcgArchiveInfo() if (!info) { - throw new Error(`Destructive Command Guard is not available for ${process.platform}-${process.arch}`) + return Promise.resolve(undefined) } - const installRoot = path.join(storageDir, "destructive-command-guard") - const finalDir = path.join(installRoot, DCG_VERSION) - const binaryPath = path.join(finalDir, info.binary) - try { - await fs.access(binaryPath) - if (process.platform !== "win32") { - await fs.chmod(binaryPath, 0o755) - } - return binaryPath - } catch { - // First install, or the managed executable was removed. - } - - await fs.mkdir(installRoot, { recursive: true }) - const stagingDir = path.join(installRoot, `${DCG_VERSION}.staging-${process.pid}-${Date.now()}`) - const archivePath = path.join(installRoot, `${info.archive}.${process.pid}.${Date.now()}.download`) - await fs.mkdir(stagingDir, { recursive: true }) - - try { - await downloadFile(`${DCG_DOWNLOAD_BASE_URL}/${info.archive}`, archivePath) - await verifyChecksum(archivePath, info.sha256) - await extractSingleBinary(archivePath, stagingDir, info) - const stagedBinary = path.join(stagingDir, info.binary) - if (process.platform !== "win32") { - await fs.chmod(stagedBinary, 0o755) - } - const version = await runProcess(stagedBinary, ["--version"], 10_000) - if (!`${version.stdout}\n${version.stderr}`.includes(DCG_VERSION.replace(/^v/, ""))) { - throw new Error("Downloaded DCG executable reported an unexpected version") - } - await promoteStagedInstallation(stagingDir, finalDir, binaryPath) - await cleanupStaleInstallations(installRoot) - return binaryPath - } finally { - await fs.rm(archivePath, { force: true }).catch(() => {}) - await fs.rm(stagingDir, { recursive: true, force: true }).catch(() => {}) - } + return ensureManagedBinaryInstalled({ + storageDir, + id: "destructive-command-guard", + version: DCG_VERSION, + versionFile: VERSION_FILE, + archiveName: info.archive, + binaryName: info.binary, + errorPrefix: "Failed to download DCG", + download: (archivePath) => downloadFile(`${DCG_DOWNLOAD_BASE_URL}/${info.archive}`, archivePath), + verifyArchive: (archivePath) => verifyChecksum(archivePath, info.sha256), + extractArchive: (archivePath, stagingDir) => extractSingleBinary(archivePath, stagingDir, info), + }) } -export function ensureDcgInstalled(storageDir: string): Promise { - const existing = installationPromises.get(storageDir) - if (existing) { - return existing - } - const promise = installDcg(storageDir).finally(() => installationPromises.delete(storageDir)) - installationPromises.set(storageDir, promise) - return promise +export function ensureDcgInstalled(storageDir: string): Promise { + return installDcg(storageDir) } diff --git a/src/services/managed-binary/__tests__/archive.spec.ts b/src/services/managed-binary/__tests__/archive.spec.ts new file mode 100644 index 0000000000..b100c5adc4 --- /dev/null +++ b/src/services/managed-binary/__tests__/archive.spec.ts @@ -0,0 +1,93 @@ +import { EventEmitter } from "events" +import { PassThrough } from "stream" + +import { spawn } from "child_process" + +import { + escapePowerShellLiteral, + extractSingleFileTarXzArchive, + extractSingleFileZipArchive, + extractTarGzArchive, + runProcess, +} from "../archive" + +vi.mock("child_process", () => ({ spawn: vi.fn() })) + +const mockSpawn = vi.mocked(spawn) + +function createChild() { + return Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + kill: vi.fn(), + }) +} + +describe("managed binary archive utilities", () => { + beforeEach(() => mockSpawn.mockReset()) + + it("runs processes without a shell and returns their output", async () => { + const child = createChild() + mockSpawn.mockReturnValue(child as unknown as ReturnType) + const processResult = runProcess("tool", ["--version"]) + child.stdout.write("1.2.3") + child.emit("close", 0) + + await expect(processResult).resolves.toEqual({ stdout: "1.2.3", stderr: "" }) + expect(mockSpawn).toHaveBeenCalledWith("tool", ["--version"], { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }) + }) + + it("escapes PowerShell single-quoted literals", () => { + expect(escapePowerShellLiteral("C:\\it's\\archive.zip")).toBe("C:\\it''s\\archive.zip") + }) + + it("extracts tar.gz archives with hardened flags", async () => { + const child = createChild() + mockSpawn.mockReturnValue(child as unknown as ReturnType) + const extraction = extractTarGzArchive("/tmp/archive.tar.gz", "/tmp/output") + child.emit("close", 0) + await extraction + + expect(mockSpawn).toHaveBeenCalledWith( + "tar", + expect.arrayContaining(["-xzf", "/tmp/archive.tar.gz", "-C", "/tmp/output", "--no-same-owner"]), + expect.objectContaining({ shell: false }), + ) + }) + + it("validates a single-file tar.xz layout before extraction", async () => { + const listing = createChild() + const extraction = createChild() + mockSpawn.mockReturnValueOnce(listing as unknown as ReturnType) + mockSpawn.mockReturnValueOnce(extraction as unknown as ReturnType) + const result = extractSingleFileTarXzArchive("/tmp/archive.tar.xz", "/tmp/output", "binary", "Tool") + listing.stdout.write("./binary\n") + listing.emit("close", 0) + await new Promise((resolve) => setImmediate(resolve)) + extraction.emit("close", 0) + await result + + expect(mockSpawn).toHaveBeenNthCalledWith( + 2, + "tar", + ["-xJf", "/tmp/archive.tar.xz", "-C", "/tmp/output", "binary"], + expect.any(Object), + ) + }) + + it("builds a single-entry-validated PowerShell ZIP extraction", async () => { + const child = createChild() + mockSpawn.mockReturnValue(child as unknown as ReturnType) + const extraction = extractSingleFileZipArchive("C:\\archive.zip", "C:\\output", "binary.exe", "Tool") + child.emit("close", 0) + await extraction + + const script = mockSpawn.mock.calls[0][1][3] + expect(script).toContain("$entries.Count -ne 1") + expect(script).toContain("binary.exe") + expect(script).toContain("Tool archive has an unexpected layout") + }) +}) diff --git a/src/services/managed-binary/__tests__/download.spec.ts b/src/services/managed-binary/__tests__/download.spec.ts new file mode 100644 index 0000000000..4c4ea195e5 --- /dev/null +++ b/src/services/managed-binary/__tests__/download.spec.ts @@ -0,0 +1,161 @@ +import { EventEmitter } from "events" +import { createReadStream, createWriteStream } from "fs" +import { get } from "https" +import type { IncomingMessage, RequestOptions } from "http" + +import { + assertSizeWithinLimit, + downloadBinaryFile, + isTrustedHttpsUrl, + resolveTrustedRedirect, + verifySha256Checksum, +} from "../download" + +vi.mock("crypto", () => ({ + createHash: vi.fn(() => ({ + update: vi.fn(), + digest: vi.fn(() => "actual-checksum"), + })), +})) + +vi.mock("fs", () => ({ + createReadStream: vi.fn(), + createWriteStream: vi.fn(), +})) + +vi.mock("https", () => ({ get: vi.fn() })) + +const trustedDomains = ["github.com", "objects.githubusercontent.com"] +const mockGet = vi.mocked(get) +const mockCreateReadStream = vi.mocked(createReadStream) +const mockCreateWriteStream = vi.mocked(createWriteStream) + +function createRequest(): EventEmitter & { setTimeout: ReturnType; destroy: ReturnType } { + return Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() }) +} + +function createResponse(statusCode: number, headers: Record = {}) { + return Object.assign(new EventEmitter(), { + statusCode, + headers, + destroy: vi.fn(), + pipe: vi.fn(), + }) +} + +describe("managed binary downloads", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("validates HTTPS URLs against hostname boundaries", () => { + expect(isTrustedHttpsUrl("https://github.com/release", trustedDomains)).toBe(true) + expect(isTrustedHttpsUrl("https://cdn.objects.githubusercontent.com/release", trustedDomains)).toBe(true) + expect(isTrustedHttpsUrl("http://github.com/release", trustedDomains)).toBe(false) + expect(isTrustedHttpsUrl("https://evilgithub.com/release", trustedDomains)).toBe(false) + expect(isTrustedHttpsUrl("not a URL", trustedDomains)).toBe(false) + }) + + it("resolves relative redirects and rejects unsafe or exhausted redirects", () => { + const options = { name: "Example", trustedDomains } + expect(resolveTrustedRedirect("https://github.com/release", "/asset", 5, options)).toBe( + "https://github.com/asset", + ) + expect(() => + resolveTrustedRedirect("https://github.com/release", "https://example.com/asset", 5, options), + ).toThrow("Example download redirected to an untrusted host") + expect(() => resolveTrustedRedirect("https://github.com/release", "/asset", 0, options)).toThrow( + "Too many Example download redirects", + ) + }) + + it("enforces configurable archive size limits", () => { + expect(() => assertSizeWithinLimit(10, 10, "Example")).not.toThrow() + expect(() => assertSizeWithinLimit(11, 10, "Example")).toThrow( + "Example archive exceeds the download size limit", + ) + }) + + it("reports the actual SHA-256 value through a caller-defined mismatch error", async () => { + const input = new EventEmitter() + mockCreateReadStream.mockReturnValue(input as ReturnType) + const verification = verifySha256Checksum( + "/tmp/archive", + "expected-checksum", + (actual) => new Error(`checksum mismatch: ${actual}`), + ) + input.emit("data", Buffer.from("archive")) + input.emit("end") + await expect(verification).rejects.toThrow("checksum mismatch: actual-checksum") + }) + + it("follows a trusted redirect and applies destination security options", async () => { + const requestOne = createRequest() + const requestTwo = createRequest() + const redirect = createResponse(302, { location: "/asset" }) + const success = createResponse(200, { "content-length": "7" }) + const output = Object.assign(new EventEmitter(), { close: vi.fn() }) + mockCreateWriteStream.mockReturnValue(output as unknown as ReturnType) + + mockGet + .mockImplementationOnce((_url, optionsOrCallback, optionalCallback) => { + const callback = + typeof optionsOrCallback === "function" + ? optionsOrCallback + : (optionalCallback as ((response: IncomingMessage) => void) | undefined) + setImmediate(() => callback?.(redirect as unknown as IncomingMessage)) + return requestOne as unknown as ReturnType + }) + .mockImplementationOnce((_url, optionsOrCallback, optionalCallback) => { + const callback = + typeof optionsOrCallback === "function" + ? optionsOrCallback + : (optionalCallback as ((response: IncomingMessage) => void) | undefined) + setImmediate(() => callback?.(success as unknown as IncomingMessage)) + return requestTwo as unknown as ReturnType + }) + + const download = downloadBinaryFile("https://github.com/release", "/tmp/archive", { + name: "Example", + trustedDomains, + timeoutMs: 1_000, + maxBytes: 10, + exclusiveDestination: true, + }) + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) + output.emit("finish") + await download + + expect(mockGet).toHaveBeenNthCalledWith(2, "https://github.com/asset", expect.any(Function)) + expect(mockCreateWriteStream).toHaveBeenCalledWith("/tmp/archive", { flags: "wx", mode: 0o600 }) + expect(requestOne.setTimeout).toHaveBeenCalledWith(1_000, expect.any(Function)) + expect(requestTwo.setTimeout).toHaveBeenCalledWith(1_000, expect.any(Function)) + }) + + it("rejects an oversized declared response before opening the destination", async () => { + const request = createRequest() + const response = createResponse(200, { "content-length": "11" }) + mockGet.mockImplementation( + ( + _url: string | URL, + optionsOrCallback: RequestOptions | ((response: IncomingMessage) => void), + optionalCallback?: (response: IncomingMessage) => void, + ) => { + const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback + setImmediate(() => callback?.(response as unknown as IncomingMessage)) + return request as unknown as ReturnType + }, + ) + + await expect( + downloadBinaryFile("https://github.com/release", "/tmp/archive", { + name: "Example", + trustedDomains, + timeoutMs: 1_000, + maxBytes: 10, + }), + ).rejects.toThrow("Example archive exceeds the download size limit") + expect(mockCreateWriteStream).not.toHaveBeenCalled() + }) +}) diff --git a/src/services/managed-binary/__tests__/install.spec.ts b/src/services/managed-binary/__tests__/install.spec.ts new file mode 100644 index 0000000000..0041706470 --- /dev/null +++ b/src/services/managed-binary/__tests__/install.spec.ts @@ -0,0 +1,88 @@ +import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises" +import { tmpdir } from "os" +import * as path from "path" + +import { ensureManagedBinaryInstalled, getManagedBinaryPaths, type ManagedBinaryInstallOptions } from "../install" + +describe("managed binary installation", () => { + let tempDir: string + + beforeEach(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), "managed-binary-")) + }) + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }) + }) + + function createOptions(overrides: Partial = {}): ManagedBinaryInstallOptions { + return { + storageDir: tempDir, + id: "example", + version: "v1.2.3", + versionFile: ".example-version", + archiveName: "example.tar.gz", + binaryName: "example", + download: vi.fn(), + verifyArchive: vi.fn(), + extractArchive: vi.fn(), + ...overrides, + } + } + + it("derives one consistent mutable installation layout", () => { + expect(getManagedBinaryPaths(createOptions())).toEqual({ + installRoot: path.join(tempDir, "example"), + binaryPath: path.join(tempDir, "example", "example"), + versionPath: path.join(tempDir, "example", ".example-version"), + stagingDir: path.join(tempDir, "example.new"), + stagedBinaryPath: path.join(tempDir, "example.new", "example"), + archivePath: path.join(tempDir, "v1.2.3-example.tar.gz"), + }) + }) + + it("reuses a current executable without invoking update callbacks", async () => { + const options = createOptions() + const paths = getManagedBinaryPaths(options) + await mkdir(paths.installRoot, { recursive: true }) + await writeFile(paths.binaryPath, "current") + await writeFile(paths.versionPath, options.version) + if (process.platform !== "win32") await chmod(paths.binaryPath, 0o600) + + await expect(ensureManagedBinaryInstalled(options)).resolves.toBe(paths.binaryPath) + expect(options.download).not.toHaveBeenCalled() + }) + + it("deduplicates concurrent installations", () => { + const options = createOptions({ download: () => new Promise(() => {}) }) + expect(ensureManagedBinaryInstalled(options)).toBe(ensureManagedBinaryInstalled(options)) + }) + + it("coordinates update, metadata promotion, and cleanup", async () => { + const calls: string[] = [] + const options = createOptions({ + download: async (archivePath) => { + calls.push("download") + await writeFile(archivePath, "archive") + }, + verifyArchive: async () => { + calls.push("verify") + }, + extractArchive: async (_archivePath, stagingDir) => { + calls.push("extract") + await writeFile(path.join(stagingDir, "example"), "binary") + }, + validateBinary: async () => { + calls.push("validate") + }, + }) + const paths = getManagedBinaryPaths(options) + + await expect(ensureManagedBinaryInstalled(options)).resolves.toBe(paths.binaryPath) + expect(calls).toEqual(["download", "verify", "extract", "validate"]) + expect(await readFile(paths.binaryPath, "utf8")).toBe("binary") + expect(await readFile(paths.versionPath, "utf8")).toBe(options.version) + await expect(access(paths.archivePath)).rejects.toThrow() + await expect(access(paths.stagingDir)).rejects.toThrow() + }) +}) diff --git a/src/services/managed-binary/archive.ts b/src/services/managed-binary/archive.ts new file mode 100644 index 0000000000..5c3f26a7fa --- /dev/null +++ b/src/services/managed-binary/archive.ts @@ -0,0 +1,105 @@ +import { spawn } from "child_process" +import * as path from "path" + +export interface ProcessResult { + stdout: string + stderr: string +} + +export function runProcess(executable: string, args: string[], timeoutMs = 30_000): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executable, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] }) + let stdout = "" + let stderr = "" + const timer = setTimeout(() => { + child.kill("SIGKILL") + reject(new Error(`${path.basename(executable)} timed out`)) + }, timeoutMs) + child.stdout?.on("data", (chunk: Buffer) => (stdout += chunk.toString())) + child.stderr?.on("data", (chunk: Buffer) => (stderr += chunk.toString())) + child.on("error", (error) => { + clearTimeout(timer) + reject(error) + }) + child.on("close", (code) => { + clearTimeout(timer) + if (code === 0) { + resolve({ stdout, stderr }) + } else { + reject(new Error(stderr.trim() || `Process exited with code ${code}`)) + } + }) + }) +} + +export function escapePowerShellLiteral(value: string): string { + return value.replace(/'/g, "''") +} + +export async function extractTarGzArchive(archivePath: string, destination: string): Promise { + const args = ["-xzf", archivePath, "-C", destination, "--no-same-owner"] + if (process.platform === "linux") { + args.push("--no-overwrite-dir") + } + await runProcess("tar", args) +} + +export async function extractTarXzArchive(archivePath: string, destination: string): Promise { + const args = ["-xJf", archivePath, "-C", destination, "--no-same-owner"] + if (process.platform === "linux") { + args.push("--no-overwrite-dir") + } + await runProcess("tar", args) +} + +export async function extractZipArchive(archivePath: string, destination: string): Promise { + if (process.platform === "win32") { + await runProcess("powershell", [ + "-NoProfile", + "-Command", + `Expand-Archive -Path '${escapePowerShellLiteral(archivePath)}' -DestinationPath '${escapePowerShellLiteral(destination)}' -Force`, + ]) + return + } + + await runProcess("unzip", ["-o", archivePath, "-d", destination]) +} + +export async function extractSingleFileZipArchive( + archivePath: string, + destination: string, + expectedFile: string, + archiveName: string, +): Promise { + const outputPath = path.join(destination, expectedFile) + const script = [ + "$ErrorActionPreference = 'Stop'", + "Add-Type -AssemblyName System.IO.Compression.FileSystem", + `$archive = [System.IO.Compression.ZipFile]::OpenRead('${escapePowerShellLiteral(archivePath)}')`, + "try {", + " $entries = @($archive.Entries | Where-Object { -not [string]::IsNullOrEmpty($_.Name) })", + ` if ($entries.Count -ne 1 -or $entries[0].FullName -ne '${escapePowerShellLiteral(expectedFile)}') { throw '${escapePowerShellLiteral(archiveName)} archive has an unexpected layout' }`, + ` [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entries[0], '${escapePowerShellLiteral(outputPath)}', $false)`, + "} finally { $archive.Dispose() }", + ].join("; ") + + await runProcess("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]) +} + +export async function extractSingleFileTarXzArchive( + archivePath: string, + destination: string, + expectedFile: string, + archiveName: string, +): Promise { + const listing = await runProcess("tar", ["-tJf", archivePath]) + const entries = listing.stdout + .split(/\r?\n/) + .map((entry) => entry.trim().replace(/^\.\//, "")) + .filter(Boolean) + if (entries.length !== 1 || entries[0] !== expectedFile) { + throw new Error(`${archiveName} archive has an unexpected layout`) + } + + await runProcess("tar", ["-xJf", archivePath, "-C", destination, expectedFile]) +} diff --git a/src/services/managed-binary/download.ts b/src/services/managed-binary/download.ts new file mode 100644 index 0000000000..c17eaed909 --- /dev/null +++ b/src/services/managed-binary/download.ts @@ -0,0 +1,149 @@ +import { createHash } from "crypto" +import { createReadStream, createWriteStream } from "fs" +import * as https from "https" + +export interface BinaryDownloadOptions { + name: string + trustedDomains: readonly string[] + timeoutMs: number + maxBytes?: number + maxRedirects?: number + exclusiveDestination?: boolean +} + +export function isTrustedHttpsUrl(url: string, trustedDomains: readonly string[]): boolean { + try { + const parsed = new URL(url) + return ( + parsed.protocol === "https:" && + trustedDomains.some((domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`)) + ) + } catch { + return false + } +} + +export function resolveTrustedRedirect( + url: string, + location: string | undefined, + redirectsRemaining: number, + options: Pick, +): string { + if (redirectsRemaining <= 0 || !location) { + throw new Error(`Too many ${options.name} download redirects`) + } + + const nextUrl = new URL(location, url).toString() + if (!isTrustedHttpsUrl(nextUrl, options.trustedDomains)) { + throw new Error(`${options.name} download redirected to an untrusted host (untrusted domain)`) + } + + return nextUrl +} + +export function assertSizeWithinLimit(size: number, maxBytes: number, name: string): void { + if (size > maxBytes) { + throw new Error(`${name} archive exceeds the download size limit`) + } +} + +export async function verifySha256Checksum( + filePath: string, + expected: string, + createMismatchError: (actual: string) => Error, +): Promise { + const hash = createHash("sha256") + await new Promise((resolve, reject) => { + const input = createReadStream(filePath) + input.on("data", (chunk) => hash.update(chunk)) + input.on("end", resolve) + input.on("error", reject) + }) + + const actual = hash.digest("hex") + if (actual !== expected) { + throw createMismatchError(actual) + } +} + +export function downloadBinaryFile(url: string, destination: string, options: BinaryDownloadOptions): Promise { + return downloadBinaryFileWithRedirects(url, destination, options, options.maxRedirects ?? 5) +} + +function downloadBinaryFileWithRedirects( + url: string, + destination: string, + options: BinaryDownloadOptions, + redirectsRemaining: number, +): Promise { + return new Promise((resolve, reject) => { + if (!isTrustedHttpsUrl(url, options.trustedDomains)) { + reject(new Error(`${options.name} download redirected to an untrusted host (untrusted domain)`)) + return + } + + const request = https.get(url, (response) => { + const status = response.statusCode ?? 0 + if ([301, 302, 303, 307, 308].includes(status)) { + response.destroy() + let nextUrl: string + try { + nextUrl = resolveTrustedRedirect(url, response.headers.location, redirectsRemaining, options) + } catch (error) { + reject(error) + return + } + downloadBinaryFileWithRedirects(nextUrl, destination, options, redirectsRemaining - 1).then( + resolve, + reject, + ) + return + } + + if (status !== 200) { + response.destroy() + reject(new Error(`${options.name} download failed with HTTP ${status}`)) + return + } + + const declaredSize = Number(response.headers["content-length"] ?? 0) + if (options.maxBytes !== undefined) { + try { + assertSizeWithinLimit(declaredSize, options.maxBytes, options.name) + } catch (error) { + response.destroy() + reject(error) + return + } + } + + let received = 0 + const output = createWriteStream( + destination, + options.exclusiveDestination ? { flags: "wx", mode: 0o600 } : undefined, + ) + response.on("data", (chunk: Buffer) => { + received += chunk.length + if (options.maxBytes !== undefined) { + try { + assertSizeWithinLimit(received, options.maxBytes, options.name) + } catch (error) { + response.destroy() + request.destroy(error as Error) + reject(error) + } + } + }) + response.on("error", reject) + response.pipe(output) + output.on("finish", () => { + output.close() + resolve() + }) + output.on("error", reject) + }) + + request.setTimeout(options.timeoutMs, () => request.destroy(new Error(`${options.name} download timed out`))) + request.on("error", reject) + }) +} diff --git a/src/services/managed-binary/install.ts b/src/services/managed-binary/install.ts new file mode 100644 index 0000000000..5257cfdf9f --- /dev/null +++ b/src/services/managed-binary/install.ts @@ -0,0 +1,131 @@ +import * as fs from "fs/promises" +import * as path from "path" + +const installationPromises = new Map>() + +export interface ManagedBinaryInstallOptions { + storageDir: string + id: string + version: string + versionFile: string + archiveName: string + binaryName: string + download: (archivePath: string) => Promise + verifyArchive: (archivePath: string) => Promise + extractArchive: (archivePath: string, stagingDir: string) => Promise + validateBinary?: (stagedBinaryPath: string) => Promise + errorPrefix?: string +} + +export interface ManagedBinaryPaths { + installRoot: string + binaryPath: string + versionPath: string + stagingDir: string + stagedBinaryPath: string + archivePath: string +} + +export function getManagedBinaryPaths( + options: Pick< + ManagedBinaryInstallOptions, + "storageDir" | "id" | "version" | "versionFile" | "archiveName" | "binaryName" + >, +): ManagedBinaryPaths { + const installRoot = path.join(options.storageDir, options.id) + const stagingDir = path.join(options.storageDir, `${options.id}.new`) + return { + installRoot, + binaryPath: path.join(installRoot, options.binaryName), + versionPath: path.join(installRoot, options.versionFile), + stagingDir, + stagedBinaryPath: path.join(stagingDir, options.binaryName), + archivePath: path.join(options.storageDir, `${options.version}-${options.archiveName}`), + } +} + +async function readInstalledVersion(versionPath: string): Promise { + try { + const version = (await fs.readFile(versionPath, "utf8")).trim() + return version || undefined + } catch { + return undefined + } +} + +async function makeExecutable(binaryPath: string): Promise { + await fs.access(binaryPath) + if (process.platform !== "win32") { + await fs.chmod(binaryPath, 0o755) + } +} + +async function cleanupStaleArchives(options: ManagedBinaryInstallOptions, currentArchivePath: string): Promise { + try { + const entries = await fs.readdir(options.storageDir) + const suffix = `-${options.archiveName}` + await Promise.all( + entries + .filter( + (name) => + (name === options.archiveName || name.endsWith(suffix)) && + path.join(options.storageDir, name) !== currentArchivePath, + ) + .map((name) => fs.rm(path.join(options.storageDir, name), { force: true }).catch(() => {})), + ) + } catch { + // Archive cleanup is cosmetic and must not invalidate a successful installation. + } +} + +async function installManagedBinary(options: ManagedBinaryInstallOptions): Promise { + const paths = getManagedBinaryPaths(options) + await fs.mkdir(options.storageDir, { recursive: true }) + const installedVersion = await readInstalledVersion(paths.versionPath) + if (installedVersion === options.version) { + try { + await makeExecutable(paths.binaryPath) + return paths.binaryPath + } catch { + // The installation is absent or incomplete, so rebuild it below. + } + } + + await fs.rm(paths.archivePath, { force: true }).catch(() => {}) + await fs.rm(paths.stagingDir, { recursive: true, force: true }).catch(() => {}) + await fs.mkdir(paths.stagingDir, { recursive: true }) + + try { + await options.download(paths.archivePath) + await options.verifyArchive(paths.archivePath) + await options.extractArchive(paths.archivePath, paths.stagingDir) + await makeExecutable(paths.stagedBinaryPath) + await options.validateBinary?.(paths.stagedBinaryPath) + await fs.writeFile(path.join(paths.stagingDir, options.versionFile), options.version, "utf-8") + await fs.rm(paths.installRoot, { recursive: true, force: true }) + await fs.rename(paths.stagingDir, paths.installRoot) + await cleanupStaleArchives(options, paths.archivePath) + return paths.binaryPath + } catch (error) { + if (!options.errorPrefix) { + throw error + } + const message = error instanceof Error ? error.message : String(error) + throw new Error(`${options.errorPrefix}: ${message}`) + } finally { + await fs.rm(paths.archivePath, { force: true }).catch(() => {}) + await fs.rm(paths.stagingDir, { recursive: true, force: true }).catch(() => {}) + } +} + +export function ensureManagedBinaryInstalled(options: ManagedBinaryInstallOptions): Promise { + const key = path.join(options.storageDir, options.id) + const existing = installationPromises.get(key) + if (existing) { + return existing + } + + const installation = installManagedBinary(options).finally(() => installationPromises.delete(key)) + installationPromises.set(key, installation) + return installation +} From 6868f5491df14b602376dd41f63ed6a78646e20e Mon Sep 17 00:00:00 2001 From: Naved Date: Wed, 29 Jul 2026 18:12:16 -0700 Subject: [PATCH 11/12] test: make DCG ZIP extraction platform-aware --- .../destructive-command-guard/__tests__/manager.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/services/destructive-command-guard/__tests__/manager.spec.ts b/src/services/destructive-command-guard/__tests__/manager.spec.ts index 36e6b06722..32831e749f 100644 --- a/src/services/destructive-command-guard/__tests__/manager.spec.ts +++ b/src/services/destructive-command-guard/__tests__/manager.spec.ts @@ -110,7 +110,13 @@ describe("Destructive Command Guard manager", () => { child.emit("close", 0) await extraction - expect(mockSpawn).toHaveBeenCalledWith("unzip", ["-o", "C:\\dcg.zip", "-d", "C:\\staging"], { + const expectedExecutable = process.platform === "win32" ? "powershell" : "unzip" + const expectedArgs = + process.platform === "win32" + ? ["-NoProfile", "-Command", "Expand-Archive -Path 'C:\\dcg.zip' -DestinationPath 'C:\\staging' -Force"] + : ["-o", "C:\\dcg.zip", "-d", "C:\\staging"] + + expect(mockSpawn).toHaveBeenCalledWith(expectedExecutable, expectedArgs, { shell: false, stdio: ["ignore", "pipe", "pipe"], }) From 2478d1f6662aee3914dd09b50de75478bb876550 Mon Sep 17 00:00:00 2001 From: Naved Merchant <14171946+navedmerchant@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:57:41 +0000 Subject: [PATCH 12/12] fix: harden managed binary install and download against review findings - Adopt a concurrently completed installation instead of removing it when another VS Code window finishes first, preventing transient ENOENT for consumers mid-swap. - Destroy the piped output stream when aborting an oversized download so the file descriptor is not leaked until GC. - Make the semble downloader test mock path-aware so the added mid-install re-check is modeled correctly. --- .../__tests__/semble-downloader.spec.ts | 8 ++--- .../managed-binary/__tests__/download.spec.ts | 32 +++++++++++++++++++ .../managed-binary/__tests__/install.spec.ts | 20 ++++++++++++ src/services/managed-binary/download.ts | 1 + src/services/managed-binary/install.ts | 9 ++++++ 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/services/code-index/semble/__tests__/semble-downloader.spec.ts b/src/services/code-index/semble/__tests__/semble-downloader.spec.ts index 8261bfd307..716ccfb076 100644 --- a/src/services/code-index/semble/__tests__/semble-downloader.spec.ts +++ b/src/services/code-index/semble/__tests__/semble-downloader.spec.ts @@ -766,11 +766,9 @@ describe("semble-downloader", () => { // Version matches ;(fs.readFile as any).mockResolvedValue("v0.4.1") // But binary is missing - let accessCallCount = 0 - ;(fs.access as any).mockImplementation(() => { - accessCallCount++ - // First call: binary path check (miss), subsequent: staged binary verify (pass) - if (accessCallCount === 1) { + ;(fs.access as any).mockImplementation((target: string) => { + // The installed binary path misses; the staged binary verifies fine. + if (target === path.join("/storage", "semble", "semble")) { return Promise.reject(new Error("ENOENT")) } return Promise.resolve(undefined) diff --git a/src/services/managed-binary/__tests__/download.spec.ts b/src/services/managed-binary/__tests__/download.spec.ts index 4c4ea195e5..37a8db1717 100644 --- a/src/services/managed-binary/__tests__/download.spec.ts +++ b/src/services/managed-binary/__tests__/download.spec.ts @@ -158,4 +158,36 @@ describe("managed binary downloads", () => { ).rejects.toThrow("Example archive exceeds the download size limit") expect(mockCreateWriteStream).not.toHaveBeenCalled() }) + + it("destroys the output stream when streamed data exceeds the size limit", async () => { + const request = createRequest() + const response = createResponse(200, { "content-length": "5" }) + const output = Object.assign(new EventEmitter(), { close: vi.fn(), destroy: vi.fn() }) + mockCreateWriteStream.mockReturnValue(output as unknown as ReturnType) + mockGet.mockImplementation( + ( + _url: string | URL, + optionsOrCallback: RequestOptions | ((response: IncomingMessage) => void), + optionalCallback?: (response: IncomingMessage) => void, + ) => { + const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback + setImmediate(() => callback?.(response as unknown as IncomingMessage)) + return request as unknown as ReturnType + }, + ) + + const download = downloadBinaryFile("https://github.com/release", "/tmp/archive", { + name: "Example", + trustedDomains, + timeoutMs: 1_000, + maxBytes: 10, + }) + await new Promise((resolve) => setImmediate(resolve)) + response.emit("data", Buffer.alloc(11)) + + await expect(download).rejects.toThrow("Example archive exceeds the download size limit") + expect(response.destroy).toHaveBeenCalled() + expect(output.destroy).toHaveBeenCalled() + expect(request.destroy).toHaveBeenCalledWith(expect.any(Error)) + }) }) diff --git a/src/services/managed-binary/__tests__/install.spec.ts b/src/services/managed-binary/__tests__/install.spec.ts index 0041706470..2de7d4a66c 100644 --- a/src/services/managed-binary/__tests__/install.spec.ts +++ b/src/services/managed-binary/__tests__/install.spec.ts @@ -85,4 +85,24 @@ describe("managed binary installation", () => { await expect(access(paths.archivePath)).rejects.toThrow() await expect(access(paths.stagingDir)).rejects.toThrow() }) + + it("adopts a valid installation completed by a concurrent process instead of replacing it", async () => { + const options = createOptions({ + download: async (archivePath) => { + await writeFile(archivePath, "archive") + }, + extractArchive: async (_archivePath, stagingDir) => { + await writeFile(path.join(stagingDir, "example"), "binary") + const paths = getManagedBinaryPaths(options) + await mkdir(paths.installRoot, { recursive: true }) + await writeFile(paths.binaryPath, "concurrent binary") + await writeFile(paths.versionPath, options.version) + }, + }) + const paths = getManagedBinaryPaths(options) + + await expect(ensureManagedBinaryInstalled(options)).resolves.toBe(paths.binaryPath) + expect(await readFile(paths.binaryPath, "utf8")).toBe("concurrent binary") + await expect(access(paths.stagingDir)).rejects.toThrow() + }) }) diff --git a/src/services/managed-binary/download.ts b/src/services/managed-binary/download.ts index c17eaed909..ad36696654 100644 --- a/src/services/managed-binary/download.ts +++ b/src/services/managed-binary/download.ts @@ -129,6 +129,7 @@ function downloadBinaryFileWithRedirects( assertSizeWithinLimit(received, options.maxBytes, options.name) } catch (error) { response.destroy() + output.destroy() request.destroy(error as Error) reject(error) } diff --git a/src/services/managed-binary/install.ts b/src/services/managed-binary/install.ts index 5257cfdf9f..8c7b85fca9 100644 --- a/src/services/managed-binary/install.ts +++ b/src/services/managed-binary/install.ts @@ -102,6 +102,15 @@ async function installManagedBinary(options: ManagedBinaryInstallOptions): Promi await makeExecutable(paths.stagedBinaryPath) await options.validateBinary?.(paths.stagedBinaryPath) await fs.writeFile(path.join(paths.stagingDir, options.versionFile), options.version, "utf-8") + const currentVersion = await readInstalledVersion(paths.versionPath) + if (currentVersion === options.version) { + try { + await makeExecutable(paths.binaryPath) + return paths.binaryPath + } catch { + // A concurrent installer left a partial install, so replace it below. + } + } await fs.rm(paths.installRoot, { recursive: true, force: true }) await fs.rename(paths.stagingDir, paths.installRoot) await cleanupStaleArchives(options, paths.archivePath)