From d48e5f68ca334d5478467986593316fe65f9ef9b Mon Sep 17 00:00:00 2001 From: Andy <119136210+AndyMik90@users.noreply.github.com> Date: Tue, 13 Jan 2026 18:56:48 +0100 Subject: [PATCH] feat(terminal): add YOLO mode to invoke Claude with --dangerously-skip-permissions (#1016) * feat(terminal): add YOLO mode to invoke Claude with --dangerously-skip-permissions Add a toggle in Developer Tools settings that enables "YOLO Mode" which starts Claude with the --dangerously-skip-permissions flag, bypassing all safety prompts. Changes: - Add dangerouslySkipPermissions setting to AppSettings interface - Add translation keys for YOLO mode (en/fr) - Modify claude-integration-handler to accept and append extra flags - Update terminal-manager and terminal-handlers to read and forward the setting - Add Switch toggle with warning styling in DevToolsSettings UI The toggle includes visual warnings (amber colors, AlertTriangle icon) to clearly indicate this is a dangerous option that bypasses Claude's permission system. Co-Authored-By: Claude Opus 4.5 * fix(terminal): address PR review issues for YOLO mode implementation - Add async readSettingsFileAsync to avoid blocking main process during settings read - Extract YOLO_MODE_FLAG constant to eliminate duplicate flag strings - Store dangerouslySkipPermissions on terminal object to persist YOLO mode across profile switches - Update switchClaudeProfile callback to pass stored YOLO mode setting These fixes address: - LOW: Synchronous file I/O in IPC handler - LOW: Flag string duplicated in invokeClaude and invokeClaudeAsync - MEDIUM: YOLO mode not persisting when switching Claude profiles Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../main/ipc-handlers/terminal-handlers.ts | 14 ++++- apps/frontend/src/main/settings-utils.ts | 29 ++++++++++ .../terminal/claude-integration-handler.ts | 57 +++++++++++++------ .../src/main/terminal/terminal-manager.ts | 12 ++-- apps/frontend/src/main/terminal/types.ts | 2 + .../components/settings/DevToolsSettings.tsx | 34 ++++++++++- .../src/shared/i18n/locales/en/settings.json | 7 ++- .../src/shared/i18n/locales/fr/settings.json | 7 ++- apps/frontend/src/shared/types/settings.ts | 2 + 9 files changed, 138 insertions(+), 26 deletions(-) diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index cf2a8778..922fe281 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -10,6 +10,7 @@ import { terminalNameGenerator } from '../terminal-name-generator'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape'; import { getClaudeCliInvocationAsync } from '../claude-cli-utils'; +import { readSettingsFileAsync } from '../settings-utils'; /** @@ -54,8 +55,17 @@ export function registerTerminalHandlers( ipcMain.on( IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, (_, id: string, cwd?: string) => { - // Use async version to avoid blocking main process during CLI detection - terminalManager.invokeClaudeAsync(id, cwd).catch((error) => { + // Wrap in async IIFE to allow async settings read without blocking + (async () => { + // Read settings asynchronously to check for YOLO mode (dangerously skip permissions) + const settings = await readSettingsFileAsync(); + const dangerouslySkipPermissions = settings?.dangerouslySkipPermissions === true; + + debugLog('[terminal-handlers] Invoking Claude with dangerouslySkipPermissions:', dangerouslySkipPermissions); + + // Use async version to avoid blocking main process during CLI detection + await terminalManager.invokeClaudeAsync(id, cwd, undefined, dangerouslySkipPermissions); + })().catch((error) => { debugError('[terminal-handlers] Failed to invoke Claude:', error); }); } diff --git a/apps/frontend/src/main/settings-utils.ts b/apps/frontend/src/main/settings-utils.ts index 96c07beb..64f3903f 100644 --- a/apps/frontend/src/main/settings-utils.ts +++ b/apps/frontend/src/main/settings-utils.ts @@ -10,6 +10,7 @@ import { app } from 'electron'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { promises as fsPromises } from 'fs'; import path from 'path'; /** @@ -58,3 +59,31 @@ export function writeSettingsFile(settings: Record): void { writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); } + +/** + * Read and parse settings from disk asynchronously. + * Returns the raw parsed settings object, or undefined if the file doesn't exist or fails to parse. + * + * This is the non-blocking version of readSettingsFile, safe to use in Electron main process + * without blocking the event loop. + * + * This function does NOT merge with defaults or perform any migrations. + * Callers are responsible for merging with DEFAULT_APP_SETTINGS. + */ +export async function readSettingsFileAsync(): Promise | undefined> { + const settingsPath = getSettingsPath(); + + try { + await fsPromises.access(settingsPath); + } catch { + return undefined; + } + + try { + const content = await fsPromises.readFile(settingsPath, 'utf-8'); + return JSON.parse(content); + } catch { + // Return undefined on parse error - caller will use defaults + return undefined; + } +} diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index 35c6feca..60638699 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -26,6 +26,12 @@ function normalizePathForBash(envPath: string): string { return process.platform === 'win32' ? envPath.replace(/;/g, ':') : envPath; } +/** + * Flag for YOLO mode (skip all permission prompts) + * Extracted as constant to ensure consistency across invokeClaude and invokeClaudeAsync + */ +const YOLO_MODE_FLAG = ' --dangerously-skip-permissions'; + // ============================================================================ // SHARED HELPERS - Used by both sync and async invokeClaude // ============================================================================ @@ -54,6 +60,7 @@ type ClaudeCommandConfig = * @param pathPrefix - PATH prefix for Claude CLI (empty string if not needed) * @param escapedClaudeCmd - Shell-escaped Claude CLI command * @param config - Configuration object with method and required options (discriminated union) + * @param extraFlags - Optional extra flags to append to the command (e.g., '--dangerously-skip-permissions') * @returns Complete shell command string ready for terminal.pty.write() * * @example @@ -69,17 +76,19 @@ export function buildClaudeShellCommand( cwdCommand: string, pathPrefix: string, escapedClaudeCmd: string, - config: ClaudeCommandConfig + config: ClaudeCommandConfig, + extraFlags?: string ): string { + const fullCmd = extraFlags ? `${escapedClaudeCmd}${extraFlags}` : escapedClaudeCmd; switch (config.method) { case 'temp-file': - return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${config.escapedTempFile} && rm -f ${config.escapedTempFile} && exec ${escapedClaudeCmd}"\r`; + return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${config.escapedTempFile} && rm -f ${config.escapedTempFile} && exec ${fullCmd}"\r`; case 'config-dir': - return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${config.escapedConfigDir} ${pathPrefix}bash -c "exec ${escapedClaudeCmd}"\r`; + return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${config.escapedConfigDir} ${pathPrefix}bash -c "exec ${fullCmd}"\r`; default: - return `${cwdCommand}${pathPrefix}${escapedClaudeCmd}\r`; + return `${cwdCommand}${pathPrefix}${fullCmd}\r`; } } @@ -371,14 +380,21 @@ export function invokeClaude( cwd: string | undefined, profileId: string | undefined, getWindow: WindowGetter, - onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void + onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void, + dangerouslySkipPermissions?: boolean ): void { debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE START =========='); debugLog('[ClaudeIntegration:invokeClaude] Terminal ID:', terminal.id); debugLog('[ClaudeIntegration:invokeClaude] Requested profile ID:', profileId); debugLog('[ClaudeIntegration:invokeClaude] CWD:', cwd); + debugLog('[ClaudeIntegration:invokeClaude] Dangerously skip permissions:', dangerouslySkipPermissions); + + // Compute extra flags for YOLO mode + const extraFlags = dangerouslySkipPermissions ? YOLO_MODE_FLAG : undefined; terminal.isClaudeMode = true; + // Store YOLO mode setting so it persists across profile switches + terminal.dangerouslySkipPermissions = dangerouslySkipPermissions; SessionHandler.releaseSessionId(terminal.id); terminal.claudeSessionId = undefined; @@ -433,7 +449,7 @@ export function invokeClaude( { mode: 0o600 } ); - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }, extraFlags); debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)'); terminal.pty.write(command); profileManager.markProfileUsed(activeProfile.id); @@ -442,7 +458,7 @@ export function invokeClaude( return; } else if (activeProfile.configDir) { const escapedConfigDir = escapeShellArg(activeProfile.configDir); - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }, extraFlags); debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)'); terminal.pty.write(command); profileManager.markProfileUsed(activeProfile.id); @@ -458,7 +474,7 @@ export function invokeClaude( debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name); } - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }, extraFlags); debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command); terminal.pty.write(command); @@ -540,14 +556,21 @@ export async function invokeClaudeAsync( cwd: string | undefined, profileId: string | undefined, getWindow: WindowGetter, - onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void + onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void, + dangerouslySkipPermissions?: boolean ): Promise { debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE START (async) =========='); debugLog('[ClaudeIntegration:invokeClaudeAsync] Terminal ID:', terminal.id); debugLog('[ClaudeIntegration:invokeClaudeAsync] Requested profile ID:', profileId); debugLog('[ClaudeIntegration:invokeClaudeAsync] CWD:', cwd); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Dangerously skip permissions:', dangerouslySkipPermissions); + + // Compute extra flags for YOLO mode + const extraFlags = dangerouslySkipPermissions ? YOLO_MODE_FLAG : undefined; terminal.isClaudeMode = true; + // Store YOLO mode setting so it persists across profile switches + terminal.dangerouslySkipPermissions = dangerouslySkipPermissions; SessionHandler.releaseSessionId(terminal.id); terminal.claudeSessionId = undefined; @@ -604,7 +627,7 @@ export async function invokeClaudeAsync( { mode: 0o600 } ); - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }, extraFlags); debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (temp file method, history-safe)'); terminal.pty.write(command); profileManager.markProfileUsed(activeProfile.id); @@ -613,7 +636,7 @@ export async function invokeClaudeAsync( return; } else if (activeProfile.configDir) { const escapedConfigDir = escapeShellArg(activeProfile.configDir); - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }, extraFlags); debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (configDir method, history-safe)'); terminal.pty.write(command); profileManager.markProfileUsed(activeProfile.id); @@ -629,7 +652,7 @@ export async function invokeClaudeAsync( debugLog('[ClaudeIntegration:invokeClaudeAsync] Using terminal environment for non-default profile:', activeProfile.name); } - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }, extraFlags); debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (default method):', command); terminal.pty.write(command); @@ -794,7 +817,7 @@ export async function switchClaudeProfile( terminal: TerminalProcess, profileId: string, getWindow: WindowGetter, - invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string) => Promise, + invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string, dangerouslySkipPermissions?: boolean) => Promise, clearRateLimitCallback: (terminalId: string) => void ): Promise<{ success: boolean; error?: string }> { // Always-on tracing @@ -875,13 +898,15 @@ export async function switchClaudeProfile( clearRateLimitCallback(terminal.id); const projectPath = terminal.projectPath || terminal.cwd; - console.warn('[ClaudeIntegration:switchClaudeProfile] Invoking Claude with profile:', profileId, '| cwd:', projectPath); + console.warn('[ClaudeIntegration:switchClaudeProfile] Invoking Claude with profile:', profileId, '| cwd:', projectPath, '| YOLO:', terminal.dangerouslySkipPermissions); debugLog('[ClaudeIntegration:switchClaudeProfile] Invoking Claude with new profile:', { terminalId: terminal.id, projectPath, - profileId + profileId, + dangerouslySkipPermissions: terminal.dangerouslySkipPermissions }); - await invokeClaudeCallback(terminal.id, projectPath, profileId); + // Pass the stored dangerouslySkipPermissions value to preserve YOLO mode across profile switches + await invokeClaudeCallback(terminal.id, projectPath, profileId, terminal.dangerouslySkipPermissions); debugLog('[ClaudeIntegration:switchClaudeProfile] Setting active profile in profile manager'); profileManager.setActiveProfile(profileId); diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index 5e8fb4c8..4c2e8d5c 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -145,7 +145,7 @@ export class TerminalManager { /** * Invoke Claude in a terminal with optional profile override (async - non-blocking) */ - async invokeClaudeAsync(id: string, cwd?: string, profileId?: string): Promise { + async invokeClaudeAsync(id: string, cwd?: string, profileId?: string, dangerouslySkipPermissions?: boolean): Promise { const terminal = this.terminals.get(id); if (!terminal) { return; @@ -164,7 +164,8 @@ export class TerminalManager { this.terminals, this.getWindow ); - } + }, + dangerouslySkipPermissions ); } @@ -172,7 +173,7 @@ export class TerminalManager { * Invoke Claude in a terminal with optional profile override * @deprecated Use invokeClaudeAsync for non-blocking behavior */ - invokeClaude(id: string, cwd?: string, profileId?: string): void { + invokeClaude(id: string, cwd?: string, profileId?: string, dangerouslySkipPermissions?: boolean): void { const terminal = this.terminals.get(id); if (!terminal) { return; @@ -191,7 +192,8 @@ export class TerminalManager { this.terminals, this.getWindow ); - } + }, + dangerouslySkipPermissions ); } @@ -208,7 +210,7 @@ export class TerminalManager { terminal, profileId, this.getWindow, - async (terminalId, cwd, profileId) => this.invokeClaudeAsync(terminalId, cwd, profileId), + async (terminalId, cwd, profileId, dangerouslySkipPermissions) => this.invokeClaudeAsync(terminalId, cwd, profileId, dangerouslySkipPermissions), (terminalId) => this.lastNotifiedRateLimitReset.delete(terminalId) ); } diff --git a/apps/frontend/src/main/terminal/types.ts b/apps/frontend/src/main/terminal/types.ts index b8ef1012..402bea7a 100644 --- a/apps/frontend/src/main/terminal/types.ts +++ b/apps/frontend/src/main/terminal/types.ts @@ -19,6 +19,8 @@ export interface TerminalProcess { worktreeConfig?: TerminalWorktreeConfig; /** Whether this terminal has a pending Claude resume that should be triggered on activation */ pendingClaudeResume?: boolean; + /** Whether Claude was invoked with --dangerously-skip-permissions (YOLO mode) */ + dangerouslySkipPermissions?: boolean; } /** diff --git a/apps/frontend/src/renderer/components/settings/DevToolsSettings.tsx b/apps/frontend/src/renderer/components/settings/DevToolsSettings.tsx index 24e1cd65..1691f697 100644 --- a/apps/frontend/src/renderer/components/settings/DevToolsSettings.tsx +++ b/apps/frontend/src/renderer/components/settings/DevToolsSettings.tsx @@ -1,10 +1,11 @@ import { useEffect, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { Code, Terminal, RefreshCw, Loader2, Check, FolderOpen } from 'lucide-react'; +import { Code, Terminal, RefreshCw, Loader2, Check, FolderOpen, AlertTriangle } from 'lucide-react'; import { Label } from '../ui/label'; import { Input } from '../ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { Button } from '../ui/button'; +import { Switch } from '../ui/switch'; import { SettingsSection } from './SettingsSection'; import type { AppSettings, SupportedIDE, SupportedTerminal } from '../../../shared/types'; @@ -364,6 +365,37 @@ export function DevToolsSettings({ settings, onSettingsChange }: DevToolsSetting )} + {/* YOLO Mode Toggle */} +
+
+
+ + +
+ { + onSettingsChange({ + ...settings, + dangerouslySkipPermissions: checked + }); + }} + /> +
+

+ {t('devtools.yoloMode.description', 'Start Claude with --dangerously-skip-permissions flag, bypassing all safety prompts. Use with extreme caution.')} +

+ {settings.dangerouslySkipPermissions && ( +

+ + {t('devtools.yoloMode.warning', 'This mode bypasses Claude\'s permission system. Only enable if you fully trust the code being executed.')} +

+ )} +
+ {/* Detection Summary */} {detectedTools && !isDetecting && (
diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index 04d4c0cb..c4930c1a 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -261,7 +261,12 @@ "detected": "Detected", "notInstalled": "Not installed", "detectedSummary": "Detected on your system:", - "noToolsDetected": "No additional tools detected (VS Code and system terminal will be used)" + "noToolsDetected": "No additional tools detected (VS Code and system terminal will be used)", + "yoloMode": { + "label": "YOLO Mode", + "description": "Start Claude with --dangerously-skip-permissions flag, bypassing all safety prompts. Use with extreme caution.", + "warning": "This mode bypasses Claude's permission system. Only enable if you fully trust the code being executed." + } }, "updates": { "title": "Updates", diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index 5888c9e7..70aabb77 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -261,7 +261,12 @@ "detected": "Détecté", "notInstalled": "Non installé", "detectedSummary": "Détecté sur votre système :", - "noToolsDetected": "Aucun outil supplémentaire détecté (VS Code et le terminal système seront utilisés)" + "noToolsDetected": "Aucun outil supplémentaire détecté (VS Code et le terminal système seront utilisés)", + "yoloMode": { + "label": "Mode YOLO", + "description": "Démarrer Claude avec le flag --dangerously-skip-permissions, contournant toutes les invites de sécurité. À utiliser avec une extrême prudence.", + "warning": "Ce mode contourne le système de permissions de Claude. N'activez que si vous faites entièrement confiance au code exécuté." + } }, "updates": { "title": "Mises à jour", diff --git a/apps/frontend/src/shared/types/settings.ts b/apps/frontend/src/shared/types/settings.ts index 5f41c78e..3ca86175 100644 --- a/apps/frontend/src/shared/types/settings.ts +++ b/apps/frontend/src/shared/types/settings.ts @@ -279,6 +279,8 @@ export interface AppSettings { customIDEPath?: string; // For 'custom' IDE preferredTerminal?: SupportedTerminal; customTerminalPath?: string; // For 'custom' terminal + // YOLO mode: invoke Claude with --dangerously-skip-permissions flag + dangerouslySkipPermissions?: boolean; // Anonymous error reporting (Sentry) - enabled by default to help improve the app sentryEnabled?: boolean; }