Skip to content

Commit 2be2c00

Browse files
committed
improve: Docker isolation lifecycle, env forwarding, and UX
- Container lifecycle: named containers (--name), docker stop on kill, --label for identification, --network host, resource limits (8g/512 pids) - Env forwarding: switched from narrow allowlist to blocklist approach — all env vars forwarded except desktop/Electron/linker-specific ones - Credential mounts: added ~/.config/gh, ~/.npmrc, ~/.netrc, GOOGLE_APPLICATION_CREDENTIALS (all read-only) - UX: auto-enable Docker when skip-permissions toggled on, hint when YOLO without Docker, Docker unavailable suggestion, "Docker" badge on task headers, shell sessions inherit Docker mode from parent task https://claude.ai/code/session_012QrKQQBc2hmjhPmkH2Fk6T
1 parent c646df4 commit 2be2c00

3 files changed

Lines changed: 163 additions & 36 deletions

File tree

electron/ipc/pty.ts

Lines changed: 127 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as pty from 'node-pty';
2-
import { execFileSync } from 'child_process';
2+
import { execFileSync, execFile } from 'child_process';
33
import fs from 'fs';
44
import type { BrowserWindow } from 'electron';
55
import { RingBuffer } from '../remote/ring-buffer.js';
@@ -13,6 +13,8 @@ interface PtySession {
1313
flushTimer: ReturnType<typeof setTimeout> | null;
1414
subscribers: Set<(encoded: string) => void>;
1515
scrollback: RingBuffer;
16+
/** Assigned container name when running in Docker mode, null otherwise. */
17+
containerName: string | null;
1618
}
1719

1820
const sessions = new Map<string, PtySession>();
@@ -158,13 +160,34 @@ export function spawnAgent(
158160
let spawnCommand: string;
159161
let spawnArgs: string[];
160162

163+
// Derive a predictable, unique container name from the agentId so we can
164+
// reliably stop it later without having to parse docker inspect output.
165+
const containerName = args.dockerMode
166+
? `parallel-code-${args.agentId.slice(0, 8)}`
167+
: null;
168+
161169
if (args.dockerMode) {
162170
const image = args.dockerImage || 'ubuntu:latest';
163171
spawnCommand = 'docker';
164172
spawnArgs = [
165173
'run',
166174
'--rm',
167175
'-it',
176+
// Predictable name so we can stop the container on kill
177+
'--name',
178+
containerName!,
179+
// Label so we can identify all containers owned by this app
180+
'--label',
181+
'parallel-code=true',
182+
// Host networking — agents need internet access for API calls and package installs.
183+
// Filesystem isolation (volume mounts) is the primary safety goal, not network isolation.
184+
'--network',
185+
'host',
186+
// Resource limits to prevent runaway containers
187+
'--memory',
188+
'8g',
189+
'--pids-limit',
190+
'512',
168191
// Mount the project directory as the only writable volume
169192
'-v',
170193
`${cwd}:${cwd}`,
@@ -200,6 +223,7 @@ export function spawnAgent(
200223
flushTimer: null,
201224
subscribers: new Set(),
202225
scrollback: new RingBuffer(),
226+
containerName,
203227
};
204228
sessions.set(args.agentId, session);
205229

@@ -324,6 +348,12 @@ export function killAgent(agentId: string): void {
324348
// notify stale listeners. Let onExit handle sessions.delete
325349
// and emitPtyEvent to avoid the race condition.
326350
session.subscribers.clear();
351+
// Stop the Docker container first so it doesn't keep running after the
352+
// local PTY process (docker run) is killed. Fire-and-forget; the PTY kill
353+
// below is the authoritative termination signal.
354+
if (session.containerName) {
355+
stopDockerContainer(session.containerName);
356+
}
327357
session.proc.kill();
328358
}
329359
}
@@ -336,6 +366,9 @@ export function killAllAgents(): void {
336366
for (const [, session] of sessions) {
337367
if (session.flushTimer) clearTimeout(session.flushTimer);
338368
session.subscribers.clear();
369+
if (session.containerName) {
370+
stopDockerContainer(session.containerName);
371+
}
339372
session.proc.kill();
340373
}
341374
// Let onExit handlers clean up sessions individually
@@ -382,29 +415,63 @@ export function getAgentCols(agentId: string): number {
382415

383416
// --- Docker mode helpers ---
384417

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-
];
418+
/**
419+
* Env vars that are desktop/host-specific and must NOT be forwarded into the
420+
* container. Everything else is forwarded so agents can use arbitrary vars
421+
* (custom API keys, feature flags, tool config, etc.) without needing an
422+
* ever-growing allowlist.
423+
*/
424+
const DOCKER_ENV_BLOCK_LIST = new Set([
425+
// Display / desktop session
426+
'DISPLAY',
427+
'WAYLAND_DISPLAY',
428+
'DBUS_SESSION_BUS_ADDRESS',
429+
'DBUS_SYSTEM_BUS_ADDRESS',
430+
'DESKTOP_SESSION',
431+
'XDG_CURRENT_DESKTOP',
432+
'XDG_RUNTIME_DIR',
433+
'XDG_SESSION_CLASS',
434+
'XDG_SESSION_ID',
435+
'XDG_SESSION_TYPE',
436+
'XDG_VTNR',
437+
'WINDOWID',
438+
'XAUTHORITY',
439+
// Electron / Node host internals
440+
'ELECTRON_RUN_AS_NODE',
441+
'ELECTRON_NO_ATTACH_CONSOLE',
442+
'ELECTRON_ENABLE_LOGGING',
443+
'ELECTRON_ENABLE_STACK_DUMPING',
444+
// Host-specific paths / linker
445+
'LD_PRELOAD',
446+
'LD_LIBRARY_PATH',
447+
'DYLD_INSERT_LIBRARIES',
448+
'DYLD_LIBRARY_PATH',
449+
// Session / PAM
450+
'LOGNAME',
451+
'MAIL',
452+
'XDG_DATA_DIRS',
453+
'XDG_CONFIG_DIRS',
454+
// Active Claude Code session markers (prevent nested session confusion)
455+
'CLAUDECODE',
456+
'CLAUDE_CODE_SESSION',
457+
'CLAUDE_CODE_ENTRYPOINT',
458+
]);
459+
460+
/** Returns true for env var names that should be blocked from Docker forwarding. */
461+
function isBlockedDockerEnvKey(key: string): boolean {
462+
if (DOCKER_ENV_BLOCK_LIST.has(key)) return true;
463+
// Block all remaining XDG_* vars not explicitly listed above
464+
if (key.startsWith('XDG_')) return true;
465+
// Block all ELECTRON_* vars not explicitly listed above
466+
if (key.startsWith('ELECTRON_')) return true;
467+
return false;
468+
}
402469

403470
function buildDockerEnvFlags(env: Record<string, string>): string[] {
404471
const flags: string[] = [];
405-
for (const key of DOCKER_ENV_FORWARD) {
406-
if (env[key]) {
407-
flags.push('-e', `${key}=${env[key]}`);
472+
for (const [key, value] of Object.entries(env)) {
473+
if (!isBlockedDockerEnvKey(key) && value !== undefined) {
474+
flags.push('-e', `${key}=${value}`);
408475
}
409476
}
410477
return flags;
@@ -415,27 +482,51 @@ function buildDockerCredentialMounts(): string[] {
415482
const home = process.env.HOME;
416483
if (!home) return mounts;
417484

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-
}
485+
/** Mount a path read-only if it is readable; silently skip if absent. */
486+
const mountIfExists = (hostPath: string): void => {
487+
try {
488+
fs.accessSync(hostPath, fs.constants.R_OK);
489+
mounts.push('-v', `${hostPath}:${hostPath}:ro`);
490+
} catch {
491+
// Path absent or unreadable — skip
492+
}
493+
};
426494

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
495+
// SSH keys for git push/pull
496+
mountIfExists(`${home}/.ssh`);
497+
498+
// Git identity / config
499+
mountIfExists(`${home}/.gitconfig`);
500+
501+
// GitHub CLI auth tokens (~/.config/gh/)
502+
mountIfExists(`${home}/.config/gh`);
503+
504+
// npm auth token
505+
mountIfExists(`${home}/.npmrc`);
506+
507+
// General HTTP/git HTTPS credentials (used by git credential helper)
508+
mountIfExists(`${home}/.netrc`);
509+
510+
// Google Application Credentials file (for Vertex AI / gcloud)
511+
const googleCredsFile = process.env.GOOGLE_APPLICATION_CREDENTIALS;
512+
if (googleCredsFile) {
513+
mountIfExists(googleCredsFile);
434514
}
435515

436516
return mounts;
437517
}
438518

519+
/**
520+
* Asynchronously stop a Docker container by name. Fire-and-forget — errors are
521+
* silently swallowed because the container may have already exited by the time
522+
* this is called.
523+
*/
524+
function stopDockerContainer(name: string): void {
525+
execFile('docker', ['stop', name], { timeout: 10_000 }, () => {
526+
// Intentionally ignore errors: container may not exist or may have already stopped.
527+
});
528+
}
529+
439530
/** Check if Docker is available on the system. */
440531
export async function isDockerAvailable(): Promise<boolean> {
441532
try {

src/components/NewTaskDialog.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,13 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
215215
if (directModeDisabled()) setDirectMode(false);
216216
});
217217

218+
// Auto-enable Docker when skip-permissions is turned on and Docker is available
219+
createEffect(() => {
220+
if (skipPermissions() && store.dockerAvailable) {
221+
setDockerMode(true);
222+
}
223+
});
224+
218225
const effectiveName = () => {
219226
const n = name().trim();
220227
if (n) return n;
@@ -598,6 +605,16 @@ export function NewTaskDialog(props: NewTaskDialogProps) {
598605
The agent will run without asking for confirmation. It can read, write, and delete
599606
files, and execute commands without your approval.
600607
</div>
608+
<Show when={!dockerMode() && store.dockerAvailable}>
609+
<div style={{ 'font-size': '11px', color: theme.fgMuted }}>
610+
Tip: Enable Docker isolation to limit the blast radius of skip-permissions mode.
611+
</div>
612+
</Show>
613+
<Show when={!store.dockerAvailable}>
614+
<div style={{ 'font-size': '11px', color: theme.fgMuted }}>
615+
Install Docker to enable container isolation for safer skip-permissions mode.
616+
</div>
617+
</Show>
601618
</Show>
602619
</div>
603620
</Show>

src/components/TaskPanel.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,23 @@ export function TaskPanel(props: TaskPanelProps) {
278278
title={props.task.savedInitialPrompt}
279279
ref={(h) => (titleEditHandle = h)}
280280
/>
281+
<Show when={props.task.dockerMode}>
282+
<span
283+
style={{
284+
'font-size': '11px',
285+
'font-weight': '600',
286+
padding: '2px 6px',
287+
'border-radius': '4px',
288+
background: `color-mix(in srgb, ${theme.fgMuted} 12%, transparent)`,
289+
color: theme.fgMuted,
290+
border: `1px solid color-mix(in srgb, ${theme.fgMuted} 25%, transparent)`,
291+
'flex-shrink': '0',
292+
'white-space': 'nowrap',
293+
}}
294+
>
295+
Docker
296+
</span>
297+
</Show>
281298
</div>
282299
<div style={{ display: 'flex', gap: '4px', 'margin-left': '8px', 'flex-shrink': '0' }}>
283300
<Show when={!props.task.directMode}>
@@ -968,6 +985,8 @@ export function TaskPanel(props: TaskPanelProps) {
968985
command={getShellCommand()}
969986
args={['-l']}
970987
cwd={props.task.worktreePath}
988+
dockerMode={props.task.dockerMode}
989+
dockerImage={props.task.dockerImage}
971990
initialCommand={initialCommand}
972991
onData={(data) => markAgentOutput(shellId, data, props.task.id)}
973992
onExit={(info) =>

0 commit comments

Comments
 (0)