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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-13 18:56:48 +01:00
committed by AndyMik90
parent 2d1d3ef153
commit d48e5f68ca
9 changed files with 138 additions and 26 deletions
@@ -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);
});
}
+29
View File
@@ -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<string, unknown>): 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<Record<string, unknown> | 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;
}
}
@@ -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<void> {
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<void>,
invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string, dangerouslySkipPermissions?: boolean) => Promise<void>,
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);
@@ -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<void> {
async invokeClaudeAsync(id: string, cwd?: string, profileId?: string, dangerouslySkipPermissions?: boolean): Promise<void> {
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)
);
}
+2
View File
@@ -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;
}
/**
@@ -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
)}
</div>
{/* YOLO Mode Toggle */}
<div className="space-y-3 rounded-md border border-amber-500/30 bg-amber-500/5 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500" />
<Label htmlFor="yolo-mode" className="text-amber-200">
{t('devtools.yoloMode.label', 'YOLO Mode')}
</Label>
</div>
<Switch
id="yolo-mode"
checked={settings.dangerouslySkipPermissions ?? false}
onCheckedChange={(checked) => {
onSettingsChange({
...settings,
dangerouslySkipPermissions: checked
});
}}
/>
</div>
<p className="text-xs text-amber-400/80">
{t('devtools.yoloMode.description', 'Start Claude with --dangerously-skip-permissions flag, bypassing all safety prompts. Use with extreme caution.')}
</p>
{settings.dangerouslySkipPermissions && (
<p className="text-xs text-amber-500 font-medium flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
{t('devtools.yoloMode.warning', 'This mode bypasses Claude\'s permission system. Only enable if you fully trust the code being executed.')}
</p>
)}
</div>
{/* Detection Summary */}
{detectedTools && !isDetecting && (
<div className="text-xs text-muted-foreground bg-muted/50 p-3 rounded-md">
@@ -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",
@@ -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",
@@ -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;
}