Skip to content

Commit c646df4

Browse files
committed
feat: add Docker isolation mode for safer YOLO execution
When Docker is available, tasks can opt into running inside a container. Only the project directory is bind-mounted (read-write), so agents cannot accidentally delete or modify files outside the project. - spawnAgent() wraps command in `docker run` when dockerMode is set - Mounts ~/.ssh and ~/.gitconfig read-only for git operations - Forwards API keys and git identity env vars into container - NewTaskDialog shows "Run in Docker container" toggle when Docker detected - SettingsDialog allows configuring the default Docker image - Docker availability checked at app startup via `docker info` - Full persistence of dockerMode/dockerImage per task https://claude.ai/code/session_012QrKQQBc2hmjhPmkH2Fk6T
1 parent b541919 commit c646df4

15 files changed

Lines changed: 310 additions & 5 deletions

File tree

electron/ipc/channels.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ export enum IPC {
8686
AskAboutCode = 'ask_about_code',
8787
CancelAskAboutCode = 'cancel_ask_about_code',
8888

89+
// Docker
90+
CheckDockerAvailable = 'check_docker_available',
91+
8992
// Notifications
9093
ShowNotification = 'show_notification',
9194
NotificationClicked = 'notification_clicked',

electron/ipc/pty.ts

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ export function spawnAgent(
8888
cols: number;
8989
rows: number;
9090
isShell?: boolean;
91+
dockerMode?: boolean;
92+
dockerImage?: string;
9193
onOutput: { __CHANNEL_ID__: string };
9294
},
9395
): void {
@@ -102,7 +104,12 @@ export function spawnAgent(
102104
throw new Error(`Command contains disallowed characters: ${command}`);
103105
}
104106

105-
validateCommand(command);
107+
// In Docker mode, we validate `docker` exists rather than the inner command
108+
if (!args.dockerMode) {
109+
validateCommand(command);
110+
} else {
111+
validateCommand('docker');
112+
}
106113

107114
// Kill any existing session with the same agentId to prevent PTY leaks
108115
const existing = sessions.get(args.agentId);
@@ -148,12 +155,40 @@ export function spawnAgent(
148155
delete spawnEnv.CLAUDE_CODE_SESSION;
149156
delete spawnEnv.CLAUDE_CODE_ENTRYPOINT;
150157

151-
const proc = pty.spawn(command, args.args, {
158+
let spawnCommand: string;
159+
let spawnArgs: string[];
160+
161+
if (args.dockerMode) {
162+
const image = args.dockerImage || 'ubuntu:latest';
163+
spawnCommand = 'docker';
164+
spawnArgs = [
165+
'run',
166+
'--rm',
167+
'-it',
168+
// Mount the project directory as the only writable volume
169+
'-v',
170+
`${cwd}:${cwd}`,
171+
'-w',
172+
cwd,
173+
// Forward env vars the agent needs (API keys, git config, etc.)
174+
...buildDockerEnvFlags(spawnEnv),
175+
// Mount SSH and git config read-only for git operations
176+
...buildDockerCredentialMounts(),
177+
image,
178+
command,
179+
...args.args,
180+
];
181+
} else {
182+
spawnCommand = command;
183+
spawnArgs = args.args;
184+
}
185+
186+
const proc = pty.spawn(spawnCommand, spawnArgs, {
152187
name: 'xterm-256color',
153188
cols: args.cols,
154189
rows: args.rows,
155-
cwd,
156-
env: spawnEnv,
190+
cwd: args.dockerMode ? undefined : cwd,
191+
env: args.dockerMode ? filteredEnv : spawnEnv,
157192
});
158193

159194
const session: PtySession = {
@@ -344,3 +379,69 @@ export function getAgentCols(agentId: string): number {
344379
const s = sessions.get(agentId);
345380
return s ? s.proc.cols : 80;
346381
}
382+
383+
// --- Docker mode helpers ---
384+
385+
/** Env vars to forward into the Docker container (API keys, git identity, etc.). */
386+
const DOCKER_ENV_FORWARD = [
387+
'ANTHROPIC_API_KEY',
388+
'OPENAI_API_KEY',
389+
'GEMINI_API_KEY',
390+
'GOOGLE_API_KEY',
391+
'GIT_AUTHOR_NAME',
392+
'GIT_AUTHOR_EMAIL',
393+
'GIT_COMMITTER_NAME',
394+
'GIT_COMMITTER_EMAIL',
395+
'TERM',
396+
'COLORTERM',
397+
'LANG',
398+
'HOME',
399+
'USER',
400+
'PATH',
401+
];
402+
403+
function buildDockerEnvFlags(env: Record<string, string>): string[] {
404+
const flags: string[] = [];
405+
for (const key of DOCKER_ENV_FORWARD) {
406+
if (env[key]) {
407+
flags.push('-e', `${key}=${env[key]}`);
408+
}
409+
}
410+
return flags;
411+
}
412+
413+
function buildDockerCredentialMounts(): string[] {
414+
const mounts: string[] = [];
415+
const home = process.env.HOME;
416+
if (!home) return mounts;
417+
418+
// Mount SSH directory read-only for git push/pull
419+
const sshDir = `${home}/.ssh`;
420+
try {
421+
fs.accessSync(sshDir, fs.constants.R_OK);
422+
mounts.push('-v', `${sshDir}:${sshDir}:ro`);
423+
} catch {
424+
// No .ssh dir — skip
425+
}
426+
427+
// Mount git config read-only
428+
const gitconfig = `${home}/.gitconfig`;
429+
try {
430+
fs.accessSync(gitconfig, fs.constants.R_OK);
431+
mounts.push('-v', `${gitconfig}:${gitconfig}:ro`);
432+
} catch {
433+
// No .gitconfig — skip
434+
}
435+
436+
return mounts;
437+
}
438+
439+
/** Check if Docker is available on the system. */
440+
export async function isDockerAvailable(): Promise<boolean> {
441+
try {
442+
execFileSync('docker', ['info'], { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
443+
return true;
444+
} catch {
445+
return false;
446+
}
447+
}

electron/ipc/register.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
countRunningAgents,
1313
killAllAgents,
1414
getAgentMeta,
15+
isDockerAvailable,
1516
} from './pty.js';
1617
import { ensurePlansDirectory, startPlanWatcher, readPlanForWorktree } from './plans.js';
1718
import { startRemoteServer } from '../remote/server.js';
@@ -124,6 +125,7 @@ export function registerAllHandlers(win: BrowserWindow): void {
124125

125126
// --- Agent commands ---
126127
ipcMain.handle(IPC.ListAgents, () => listAgents());
128+
ipcMain.handle(IPC.CheckDockerAvailable, () => isDockerAvailable());
127129

128130
// --- Task commands ---
129131
ipcMain.handle(IPC.CreateTask, (_e, args) => {

electron/preload.cjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ const ALLOWED_CHANNELS = new Set([
7676
'get_remote_status',
7777
// Plan
7878
'plan_content',
79+
'read_plan_content',
80+
// Docker
81+
'check_docker_available',
7982
// Ask about code
8083
'ask_about_code',
8184
'cancel_ask_about_code',

src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
setNewTaskDropUrl,
4444
validateProjectPaths,
4545
setPlanContent,
46+
setDockerAvailable,
4647
} from './store/store';
4748
import { isGitHubUrl } from './lib/github-url';
4849
import type { PersistedWindowState } from './store/types';
@@ -287,6 +288,10 @@ function App() {
287288
})();
288289

289290
await loadAgents();
291+
invoke<boolean>(IPC.CheckDockerAvailable).then(
292+
(available) => setDockerAvailable(available),
293+
() => setDockerAvailable(false),
294+
);
290295
await loadState();
291296

292297
// Restore plan content for tasks that had a plan file before restart

src/components/NewTaskDialog.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
hasDirectModeTask,
1616
getGitHubDropDefaults,
1717
setPrefillPrompt,
18+
setDockerAvailable,
19+
setDockerImage,
1820
} from '../store/store';
1921
import { toBranchName, sanitizeBranchPrefix } from '../lib/branch-name';
2022
import { cleanTaskName } from '../lib/clean-task-name';
@@ -42,6 +44,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
4244
const [selectedDirs, setSelectedDirs] = createSignal<Set<string>>(new Set());
4345
const [directMode, setDirectMode] = createSignal(false);
4446
const [skipPermissions, setSkipPermissions] = createSignal(false);
47+
const [dockerMode, setDockerMode] = createSignal(false);
4548
const [branchPrefix, setBranchPrefix] = createSignal('');
4649
let promptRef!: HTMLTextAreaElement;
4750
let formRef!: HTMLFormElement;
@@ -105,8 +108,14 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
105108
setLoading(false);
106109
setDirectMode(false);
107110
setSkipPermissions(false);
111+
setDockerMode(false);
108112

109113
void (async () => {
114+
// Check Docker availability in background
115+
invoke<boolean>(IPC.CheckDockerAvailable).then(
116+
(available) => setDockerAvailable(available),
117+
() => setDockerAvailable(false),
118+
);
110119
if (store.availableAgents.length === 0) {
111120
await loadAgents();
112121
}
@@ -296,6 +305,8 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
296305
initialPrompt: isFromDrop ? undefined : p,
297306
githubUrl: ghUrl,
298307
skipPermissions: agentSupportsSkipPermissions() && skipPermissions(),
308+
dockerMode: dockerMode() || undefined,
309+
dockerImage: dockerMode() ? store.dockerImage : undefined,
299310
});
300311
} else {
301312
taskId = await createTask({
@@ -307,6 +318,8 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
307318
branchPrefixOverride: prefix,
308319
githubUrl: ghUrl,
309320
skipPermissions: agentSupportsSkipPermissions() && skipPermissions(),
321+
dockerMode: dockerMode() || undefined,
322+
dockerImage: dockerMode() ? store.dockerImage : undefined,
310323
});
311324
}
312325
// Drop flow: prefill prompt without auto-sending
@@ -589,6 +602,72 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
589602
</div>
590603
</Show>
591604

605+
{/* Docker isolation toggle */}
606+
<Show when={store.dockerAvailable}>
607+
<div
608+
data-nav-field="docker-mode"
609+
style={{ display: 'flex', 'flex-direction': 'column', gap: '8px' }}
610+
>
611+
<label
612+
style={{
613+
display: 'flex',
614+
'align-items': 'center',
615+
gap: '8px',
616+
'font-size': '12px',
617+
color: theme.fg,
618+
cursor: 'pointer',
619+
}}
620+
>
621+
<input
622+
type="checkbox"
623+
checked={dockerMode()}
624+
onChange={(e) => setDockerMode(e.currentTarget.checked)}
625+
style={{ 'accent-color': theme.accent, cursor: 'inherit' }}
626+
/>
627+
Run in Docker container
628+
</label>
629+
<Show when={dockerMode()}>
630+
<div
631+
style={{
632+
'font-size': '12px',
633+
color: theme.success ?? theme.accent,
634+
background: `color-mix(in srgb, ${theme.success ?? theme.accent} 8%, transparent)`,
635+
padding: '8px 12px',
636+
'border-radius': '8px',
637+
border: `1px solid color-mix(in srgb, ${theme.success ?? theme.accent} 20%, transparent)`,
638+
}}
639+
>
640+
The agent will run inside a Docker container. Only the project directory is mounted
641+
— files outside the project are protected from accidental deletion.
642+
</div>
643+
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px' }}>
644+
<label
645+
style={{ 'font-size': '11px', color: theme.fgMuted, 'white-space': 'nowrap' }}
646+
>
647+
Image:
648+
</label>
649+
<input
650+
type="text"
651+
value={store.dockerImage}
652+
onInput={(e) => setDockerImage(e.currentTarget.value)}
653+
placeholder="ubuntu:latest"
654+
style={{
655+
flex: '1',
656+
background: theme.bgInput,
657+
border: `1px solid ${theme.border}`,
658+
'border-radius': '6px',
659+
padding: '5px 10px',
660+
color: theme.fg,
661+
'font-size': '12px',
662+
'font-family': "'JetBrains Mono', monospace",
663+
outline: 'none',
664+
}}
665+
/>
666+
</div>
667+
</Show>
668+
</div>
669+
</Show>
670+
592671
<Show when={ignoredDirs().length > 0 && !directMode()}>
593672
<SymlinkDirPicker
594673
dirs={ignoredDirs()}

src/components/SettingsDialog.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
setDesktopNotificationsEnabled,
1313
setInactiveColumnOpacity,
1414
setEditorCommand,
15+
setDockerImage,
1516
} from '../store/store';
1617
import { CustomAgentEditor } from './CustomAgentEditor';
1718
import { mod } from '../lib/platform';
@@ -262,6 +263,66 @@ export function SettingsDialog(props: SettingsDialogProps) {
262263
</div>
263264
</div>
264265

266+
<Show when={store.dockerAvailable}>
267+
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px' }}>
268+
<div
269+
style={{
270+
'font-size': '11px',
271+
color: theme.fgMuted,
272+
'text-transform': 'uppercase',
273+
'letter-spacing': '0.05em',
274+
'font-weight': '600',
275+
}}
276+
>
277+
Docker Isolation
278+
</div>
279+
<div
280+
style={{
281+
display: 'flex',
282+
'flex-direction': 'column',
283+
gap: '6px',
284+
padding: '8px 12px',
285+
'border-radius': '8px',
286+
background: theme.bgInput,
287+
border: `1px solid ${theme.border}`,
288+
}}
289+
>
290+
<label
291+
style={{
292+
display: 'flex',
293+
'align-items': 'center',
294+
gap: '10px',
295+
}}
296+
>
297+
<span style={{ 'font-size': '13px', color: theme.fg, 'white-space': 'nowrap' }}>
298+
Default image
299+
</span>
300+
<input
301+
type="text"
302+
value={store.dockerImage}
303+
onInput={(e) => setDockerImage(e.currentTarget.value)}
304+
placeholder="ubuntu:latest"
305+
style={{
306+
flex: '1',
307+
background: theme.taskPanelBg,
308+
border: `1px solid ${theme.border}`,
309+
'border-radius': '6px',
310+
padding: '6px 10px',
311+
color: theme.fg,
312+
'font-size': '13px',
313+
'font-family': "'JetBrains Mono', monospace",
314+
outline: 'none',
315+
}}
316+
/>
317+
</label>
318+
<span style={{ 'font-size': '11px', color: theme.fgSubtle }}>
319+
Docker image used when "Run in Docker container" is enabled for a task. The agent
320+
runs inside the container with only the project directory mounted.
321+
</span>
322+
</div>
323+
</div>
324+
</Show>
325+
265326
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '10px' }}>
266327
<div
267328
style={{

src/components/TaskPanel.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1236,6 +1236,8 @@ export function TaskPanel(props: TaskPanelProps) {
12361236
: []),
12371237
]}
12381238
cwd={props.task.worktreePath}
1239+
dockerMode={props.task.dockerMode}
1240+
dockerImage={props.task.dockerImage}
12391241
onExit={(code) => markAgentExited(a().id, code)}
12401242
onData={(data) => markAgentOutput(a().id, data, props.task.id)}
12411243
onPromptDetected={(text) => setLastPrompt(props.task.id, text)}

0 commit comments

Comments
 (0)