Skip to content

06 — Bash Execution Engine: Sandboxes, Pipelines & Process Lifecycle

🌐 Language: English | 中文版 → 📖 Read Online → — Sidebar nav, dark mode & full-text search. Better than raw GitHub.

Scope: tools/BashTool/ (18 files, ~580KB), utils/Shell.ts, utils/ShellCommand.ts, utils/bash/ (15 files, ~430KB), utils/sandbox/ (2 files, ~37KB), utils/shell/ (10 files, ~114KB)

One-liner: How Claude Code safely executes arbitrary shell commands — from parsing ls && rm -rf / to OS-level sandboxing — without losing sleep.


Architecture Overview

06 bash engine 1


1. BashTool: The Outer Shell (Pun Intended)

The entry point is BashTool.tsx — a 1144-line tool definition built with the standard buildTool() pattern. Its input schema is deceptively simple:

typescript
// 源码位置: src/tools/BashTool/BashTool.tsx:45-54
z.strictObject({
  command: z.string(),
  timeout: semanticNumber(z.number().optional()),
  description: z.string().optional(),
  run_in_background: semanticBoolean(z.boolean().optional()),
  dangerouslyDisableSandbox: semanticBoolean(z.boolean().optional()),
  _simulatedSedEdit: z.object({...}).optional()  // HIDDEN from model
})

The Hidden _simulatedSedEdit Field

This is a security-critical design: _simulatedSedEdit is always omitted from the model-facing schema. It's set internally by the permission dialog after a user approves a sed edit preview. If exposed, the model could bypass permission checks by pairing an innocuous command with an arbitrary file write.

Command Classification

Before any command runs, BashTool classifies it for UI purposes:

typescript
// Search commands → collapsible display
const BASH_SEARCH_COMMANDS = new Set(['find', 'grep', 'rg', 'ag', ...])

// Read commands → collapsible display  
const BASH_READ_COMMANDS = new Set(['cat', 'head', 'tail', 'jq', 'awk', ...])

// Semantic-neutral → skipped in classification
const BASH_SEMANTIC_NEUTRAL_COMMANDS = new Set(['echo', 'printf', 'true', ...])

// Silent commands → show "Done" instead of "(No output)"
const BASH_SILENT_COMMANDS = new Set(['mv', 'cp', 'rm', 'mkdir', ...])

For compound commands (ls && echo "---" && ls dir2), all parts must be search/read for the whole command to be collapsible. Semantic-neutral commands are transparent.


2. The runShellCommand() Generator

The heart of execution is an AsyncGenerator — a design that elegantly unifies progress reporting with command completion:

typescript
// 源码位置: src/tools/BashTool/BashTool.tsx:200-280
async function* runShellCommand({
  input, abortController, setAppState, ...
}): AsyncGenerator<ProgressUpdate, ExecResult, void> {
  
  // 1. Determine if auto-backgrounding is allowed
  const shouldAutoBackground = !isBackgroundTasksDisabled 
    && isAutobackgroundingAllowed(command)
  
  // 2. Execute via Shell.exec()
  const shellCommand = await exec(command, signal, 'bash', {
    timeout: timeoutMs,
    onProgress(lastLines, allLines, totalLines, totalBytes) {
      // Wake the generator to yield progress
      resolveProgress?.()
    },
    shouldUseSandbox: shouldUseSandbox(input),
    shouldAutoBackground
  })
  
  // 3. Wait for initial threshold (2s) before showing progress
  const initialResult = await Promise.race([
    resultPromise,
    new Promise(r => setTimeout(r, PROGRESS_THRESHOLD_MS))
  ])
  if (initialResult !== null) return initialResult  // Fast command

  // 4. Progress loop driven by shared poller
  TaskOutput.startPolling(shellCommand.taskOutput.taskId)
  while (true) {
    const result = await Promise.race([resultPromise, progressSignal])
    if (result !== null) return result
    if (backgroundShellId) return backgroundResult
    yield { type: 'progress', output, elapsedTimeSeconds, ... }
  }
}

The Three Backgrounding Paths

PathTriggerWho Decides
Explicitrun_in_background: trueThe model
TimeoutCommand exceeds default timeoutshellCommand.onTimeout()
Assistant-modeBlocking > 15s in main agentsetTimeout() with ASSISTANT_BLOCKING_BUDGET_MS
UserCtrl+B during executionregisterForeground()background()

The sleep command is specifically blocked from auto-backgrounding — it runs in foreground unless explicitly backgrounded.


3. Shell Execution Layer (Shell.ts)

Shell Discovery

Claude Code is opinionated about which shells it supports:

typescript
// 源码位置: src/utils/Shell.ts:30-50
export async function findSuitableShell(): Promise<string> {
  // 1. Check CLAUDE_CODE_SHELL override (must be bash or zsh)
  // 2. Check $SHELL (must be bash or zsh)
  // 3. Probe: which zsh, which bash
  // 4. Search fallback paths: /bin, /usr/bin, /usr/local/bin, /opt/homebrew/bin
  // 5. Order by user preference (SHELL=bash → bash first)
}

Only bash and zsh are supported. Fish, dash, csh — all rejected. The comment is blunt: "if we ever want to add support for new shells we'll need to update our Bash tool parsing."

Process Spawning: File Mode vs. Pipe Mode

A critical architectural decision drives I/O performance:

File Mode (default for bash commands):

typescript
// Both stdout AND stderr go to the SAME file fd
const outputHandle = await open(taskOutput.path,
  process.platform === 'win32' ? 'w' :
    O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW  // SECURITY: anti-symlink
)

spawn(shell, args, {
  stdio: ['pipe', outputHandle.fd, outputHandle.fd],  // merged fd!
  detached: true,
  windowsHide: true
})

This means stderr is interleaved with stdout chronologically — no separate stderr handling. The code comments explain the O_APPEND atomicity guarantee on POSIX ("seek-to-end + write") and the Windows workaround ('w' mode because 'a' strips FILE_WRITE_DATA, causing MSYS2/Cygwin to silently discard all output).

Pipe Mode (for hooks/callbacks):

typescript
spawn(shell, args, {
  stdio: ['pipe', 'pipe', 'pipe']
})
// StreamWrapper instances funnel data through TaskOutput

CWD Tracking

Each command ends with pwd -P >| /tmp/claude-XXXX-cwd. After the child exits, a synchronous readFileSync updates the CWD:

typescript
void shellCommand.result.then(async result => {
  if (!preventCwdChanges && !result.backgroundTaskId) {
    let newCwd = readFileSync(cwdFilePath, 'utf8').trim()
    if (newCwd.normalize('NFC') !== cwd) {  // Unicode normalization!
      setCwd(newCwd, cwd)
      invalidateSessionEnvCache()
      void onCwdChangedForHooks(cwd, newCwd)
    }
  }
})

The NFC normalization handles macOS APFS which stores paths as NFD — without it, Unicode paths would falsely trigger a "changed" event on every command.


4. ShellCommand: The Process Wrapper

ShellCommand.ts wraps each child_process.spawn() with lifecycle management:

The Size Watchdog

Background tasks write directly to a file fd with no JS involvement. A stuck append loop once filled 768GB of disk. The fix:

typescript
// 源码位置: src/utils/ShellCommand.ts:310-325
#startSizeWatchdog(): void {
  this.#sizeWatchdog = setInterval(() => {
    void stat(this.taskOutput.path).then(s => {
      if (s.size > this.#maxOutputBytes && this.#status === 'backgrounded') {
        this.#killedForSize = true
        this.#doKill(SIGKILL)
      }
    })
  }, SIZE_WATCHDOG_INTERVAL_MS)  // 5 seconds
}

Why exit Not close

The code uses 'exit' instead of 'close' to detect child termination:

close waits for stdio to close, which includes grandchild processes that inherit file descriptors (e.g. sleep 30 &). exit fires when the shell itself exits, returning control immediately.

Process termination uses tree-kill with SIGKILL — no gentle SIGTERM grace period. The entire process tree gets killed.


5. Bash Provider: The Command Assembly Line

bashProvider.ts builds the actual command string that gets passed to the shell:

source /tmp/snapshot.sh 2>/dev/null || true
&& eval '<quoted-user-command>'
&& pwd -P >| /tmp/claude-XXXX-cwd

Shell Snapshot

Before the first command, createAndSaveSnapshot() captures the user's shell environment (PATH, aliases, functions) into a temp file. Subsequent commands source this snapshot instead of running full login shell initialization (-l flag is skipped).

If the snapshot file vanishes mid-session (tmpdir cleanup), the provider falls back to -l mode automatically:

typescript
if (snapshotFilePath) {
  try { await access(snapshotFilePath) }
  catch { snapshotFilePath = undefined }  // Fallback to login shell
}

ExtGlob Security

Extended glob patterns (bash extglob, zsh EXTENDED_GLOB) are disabled before every command:

bash
shopt -u extglob 2>/dev/null || true   # bash
setopt NO_EXTENDED_GLOB 2>/dev/null     # zsh

Why? Malicious filenames containing glob patterns could expand after security validation but before execution.

The eval Wrapper

User commands are wrapped in eval '<command>' to enable a second parsing pass where aliases (loaded from the snapshot) are available for expansion.


6. OS-Level Sandboxing

sandbox-adapter.ts (986 lines) bridges Claude Code's settings system with @anthropic-ai/sandbox-runtime:

Platform Support

PlatformTechnologyNotes
macOSsandbox-exec (seatbelt)Profile-based, glob-aware
Linuxbubblewrap (bwrap) + seccompNamespace isolation, no glob support
WSL2bubblewrapSupported since WSL2
WSL1Not supported
WindowsNot supported (PowerShell sandboxing uses different path)

Config Conversion

Settings are converted to SandboxRuntimeConfig:

typescript
// 源码位置: src/utils/sandbox/sandbox-adapter.ts:120-160
function convertToSandboxRuntimeConfig(settings): SandboxRuntimeConfig {
  // 1. Extract network domains from WebFetch permission rules
  // 2. Extract filesystem paths from Edit/Read rules
  // 3. Always deny writes to settings.json (sandbox escape prevention!)
  // 4. Always deny writes to .claude/skills (same privilege as commands)
  // 5. Block bare-git-repo attack vectors (HEAD, objects, refs, hooks, config)
  // 6. Detect git worktrees → allow writes to main repo
  // 7. Include --add-dir directories in allowWrite
  // 8. Configure ripgrep path for sandbox-internal grep
}

Security: Settings File Protection

The sandbox unconditionally denies writes to settings files:

typescript
const settingsPaths = SETTING_SOURCES.map(source =>
  getSettingsFilePathForSource(source)
).filter(p => p !== undefined)
denyWrite.push(...settingsPaths)
denyWrite.push(getManagedSettingsDropInDir())

This prevents a sandboxed command from modifying its own sandbox rules — a classic sandbox escape vector.

The Bare Git Repo Attack

A fascinating security measure blocks an attack where a sandboxed process plants files (HEAD, objects/, refs/) to make the working directory look like a bare git repo. When Claude's unsandboxed git later runs, is_git_directory() returns true, and a malicious config with core.fsmonitor escapes the sandbox:

typescript
const bareGitRepoFiles = ['HEAD', 'objects', 'refs', 'hooks', 'config']
for (const gitFile of bareGitRepoFiles) {
  const p = resolve(dir, gitFile)
  try {
    statSync(p)        // File exists → deny writes (ro-bind)
    denyWrite.push(p)
  } catch {
    bareGitRepoScrubPaths.push(p)  // Doesn't exist → scrub after command
  }
}

Post-command, scrubBareGitRepoFiles() removes any planted files before unsandboxed git can see them.


7. Complete Execution Flow

06 bash engine 3


Transferable Design Patterns

The following patterns can be directly applied to other CLI tools or process orchestration systems.

Pattern 1: Merge stdout and stderr into a Single fd

Scenario: Concurrent writes to separate stdout/stderr pipes arrive out of order. Practice: Piping both to the same file descriptor with O_APPEND ensures chronological interleaving without tearing. Claude Code application: spawn(shell, args, { stdio: ['pipe', outputHandle.fd, outputHandle.fd] }).

Pattern 2: Zero-Token Side Channel

Scenario: CLI tools need to communicate metadata (hints, plugin suggestions) without inflating the LLM's context window. Practice: Emit structured tags to stderr, scan and strip them before passing output to the model. Claude Code application: <claude-code-hint /> tags in stderr are extracted by extractClaudeCodeHints() then stripped — the model never sees them.

typescript
const extracted = extractClaudeCodeHints(strippedStdout, input.command)
strippedStdout = extracted.stripped  // Model sees clean output
if (isMainThread && extracted.hints.length > 0) {
  for (const hint of extracted.hints) maybeRecordPluginHint(hint)
}

Pattern 3: AsyncGenerator for Progress Reporting

Scenario: Long-running subprocess needs to report incremental progress while still delivering a final result. Practice: Use an AsyncGeneratoryield produces progress updates, return delivers the final result. The consumer uses Promise.race([resultPromise, progressSignal]) to handle both in a single await. Claude Code application: runShellCommand() yields progress every 1s while the command runs, then returns ExecResult on completion.


Summary

ComponentLinesRole
BashTool.tsx1,144Tool definition, input/output schema, classification
bashPermissions.ts~2,500Permission matching with wildcard patterns
bashSecurity.ts~2,600Command security classification
readOnlyValidation.ts~1,700Read-only constraint checking
pathValidation.ts~1,100Path traversal and escape detection
Shell.ts475Shell discovery, process spawning, CWD tracking
ShellCommand.ts466Process lifecycle, backgrounding, timeouts
sandbox-adapter.ts986Settings conversion, OS sandbox orchestration
bashProvider.ts256Command assembly, snapshots, eval wrapping
bash/ parsers~7,000+AST parsing, heredoc, quoting, pipe handling

The Bash execution engine is Claude Code's most security-sensitive subsystem. It demonstrates a defense-in-depth approach: application-level command parsing → permission rules → sandbox wrapping → OS-level kernel enforcement. Each layer independently prevents different classes of attacks, and the system degrades gracefully when individual layers are unavailable.


Next: 07 — Permission Pipeline →

Previous: ← 05 — Hook System

Released under the MIT License.