Shell execution, snapshot environments, command building, 23 security validators, permission plumbing, sandboxing, and background tasks.
The Bash Tool is Claude Code's gateway to the operating system. It wraps a single user command string through seven distinct layers before any subprocess is spawned, and then another three layers as output flows back. Understanding those layers is the key to understanding every edge-case behavior in the tool.
The five source directories that matter:
Top-level tool definition, permission dispatch, sed-edit shim, UI classification, output shaping
bashSecurity.ts — 23 validators; bashPermissions.ts — rule matching + env-var stripping
AST parsing, command splitting, pipe rearrangement, heredoc handling, ShellSnapshot
BashProvider — builds exec command string; output limits; shell detection; powershell path
SandboxManager — filesystem + network policy enforcement at process-spawn time
The tool exposes a public schema to the model and a full schema used internally. One field (_simulatedSedEdit) is deliberately hidden from the model to prevent the model from bypassing the sed-edit permission dialog.
// tools/BashTool/BashTool.tsx — fullInputSchema (internal)
const fullInputSchema = lazySchema(() => z.strictObject({
command: z.string(), // the shell command
timeout: z.number().optional(), // ms, max = getMaxTimeoutMs()
description: z.string().optional(), // model-facing active-voice summary
run_in_background: z.boolean().optional(),
dangerouslyDisableSandbox: z.boolean().optional(),
// NEVER exposed to model — set only by sed-edit permission dialog
_simulatedSedEdit: z.object({
filePath: z.string(),
newContent: z.string()
}).optional()
}))
// Public schema omits _simulatedSedEdit (and optionally run_in_background)
const inputSchema = lazySchema(() =>
isBackgroundTasksDisabled
? fullInputSchema().omit({ run_in_background: true, _simulatedSedEdit: true })
: fullInputSchema().omit({ _simulatedSedEdit: true })
)
If the model could set _simulatedSedEdit, it could write arbitrary file content while pairing it with a harmless-looking command like echo done. The permission dialog would show the echo, not the file write. Hiding the field from the schema makes this structurally impossible.
// tools/BashTool/BashTool.tsx — outputSchema
z.object({
stdout: z.string(),
stderr: z.string(),
interrupted: z.boolean(),
rawOutputPath: z.string().optional(), // MCP large-output path
isImage: z.boolean().optional(), // base64 PNG/JPEG detected
backgroundTaskId: z.string().optional(), // set when run_in_background=true
backgroundedByUser: z.boolean().optional(),
assistantAutoBackgrounded: z.boolean().optional(), // auto-bg after 15s
dangerouslyDisableSandbox: z.boolean().optional(),
returnCodeInterpretation: z.string().optional(), // semantic exit-code note
noOutputExpected: z.boolean().optional(), // shows "Done" vs "(No output)"
persistedOutputPath: z.string().optional(), // >30KB → written to disk
persistedOutputSize: z.number().optional()
})
The shell provider uses 2>&1 — stderr is merged into stdout at the file-descriptor level. The stderr field in the output schema is therefore always empty for the normal execution path. Callers that expect stderr to carry error text will be surprised. Only the shell-reset append path writes to the stderr field directly.
Every time Claude Code starts a session, it runs a one-time script that captures the user's interactive shell environment into a snapshot file stored at ~/.claude/shell-snapshots/snapshot-{shell}-{timestamp}-{random}.sh. Every subsequent command sources this snapshot before executing.
This gives every Bash tool command access to the user's aliases, functions, PATH, and shell options — without spawning an expensive interactive login shell for each command.
The snapshot script sources the user's config file with < /dev/null (so no TTY-dependent prompts fire), then appends:
unalias -a 2>/dev/null || true — avoids alias "freeze" inside function definitions.typeset -f (zsh) or declare -F | base64-encode each function (bash). Completion functions starting with a single underscore are filtered out; double-underscore helpers (mise, pyenv) are kept.setopt lines (zsh) or shopt -p + set -o on | awk lines (bash). Then forcibly enables expand_aliases.alias -- form, stripping winpty aliases on Windows.rg is absent, injects a shell function backed by Bun's embedded ripgrep using the ARGV0 dispatch trick.find/grep with embedded bfs/ugrep.export PATH=... from process.env.PATH.// utils/bash/ShellSnapshot.ts — snapshot creation (simplified)
export const createAndSaveSnapshot = async (binShell: string) => {
const configFile = getConfigFile(binShell) // .zshrc / .bashrc / .profile
const configFileExists = await pathExists(configFile)
const snapshotPath = join(
getClaudeConfigHomeDir(), 'shell-snapshots',
`snapshot-${shellType}-${Date.now()}-${randomId}.sh`
)
const script = await getSnapshotScript(binShell, snapshotPath, configFileExists)
execFile(binShell, ['-i', '-c', script], { timeout: 10000 })
return snapshotPath
}
The promise is wrapped in .catch() and resolves to undefined. In buildExecCommand, if snapshotFilePath is undefined, the command is spawned with -l (login shell flag) so it still initialises from the user's profile. The session degrades gracefully — no aliases or custom functions, but commands still execute.
There is also a TOCTOU-aware re-check: before each command, access(snapshotFilePath) verifies the file still exists. If the OS cleaned up /tmp mid-session, lastSnapshotFilePath is cleared and the login-shell fallback is re-engaged.
buildExecCommand() in utils/shell/bashProvider.ts assembles the full shell string that is actually passed to spawn(). The pipeline has six stages:
eval quoting: The snapshot is sourced in the same shell invocation as the user command. Bash parses the entire line before executing, so aliases from the snapshot aren't yet available at parse time — they only become available after source snapshot runs. Using eval 'command' creates a second parse pass where the snapshot aliases are now live. The function singleQuoteForEval() wraps the command in single quotes, escaping internal single quotes as '"'"' (not \', which would break jq/awk filters containing !=).
Pipe rearrangement: Without special handling, eval 'rg foo | wc -l' < /dev/null causes wc to read from /dev/null (outputting 0) while rg waits forever on the inherited stdin pipe. The fix: inject < /dev/null between the first command and the pipe, so only the first command gets the null stdin. The parser handles this in rearrangePipeCommand().
// Before: wc reads /dev/null, rg blocks
eval 'rg foo | wc -l' < /dev/null
// After: rg reads /dev/null, wc reads rg's output
eval 'rg foo' < /dev/null | wc -l
The pipe rearrangement bails (falls back to whole-command quoting) for:
$() — shell-quote mis-parses them$VAR) — shell-quote drops themfor/while/if/case) — can't find pipe boundary'\' payload '\')After sourcing the snapshot (which may re-enable extglob from user settings), the provider injects a command to disable extended glob patterns:
// bash
shopt -u extglob 2>/dev/null || true
// zsh
setopt NO_EXTENDED_GLOB 2>/dev/null || true
// When CLAUDE_CODE_SHELL_PREFIX is set (shell may differ)
{ shopt -u extglob || setopt NO_EXTENDED_GLOB; } >/dev/null 2>&1 || true
Extended globs (!(pattern), @(a|b), etc.) can be triggered by filenames created after our security validation completes. A file named !(safe_file) in the working directory could match and expand into a glob that hits arbitrary paths. Disabling extglob post-snapshot prevents this class of post-validation expansion attack.
bashCommandIsSafe() in tools/BashTool/bashSecurity.ts runs a chain of named validators. Each validator returns one of three behaviors:
Early-allow: command is safe, skip all remaining validators
No opinion — continue to next validator in chain
Trigger a permission dialog — the command may be dangerous
The validators run in order. The first non-passthrough result wins.
-rf), or continuation operator (&&, ;). Catches command fragments that only make sense as a second half of something.jq with env, path, builtins, modulemeta, debug — jq intrinsics that can leak the runtime environment or trigger RCE via jq modules.jq --args, --jsonargs, --rawfile, --slurpfile, or --arg patterns that could read arbitrary files or inject shell arguments.;, &&, ||, | — compound commands. This is the primary multi-command junction check. Most interesting permission prompts come from here.$BASH_ENV, $ENV, $CDPATH, $IFS — shell variables that affect global shell behavior and can be weaponized to reroute execution.; in bash, making it a common injection vector that bypasses naive ;-only checks.$(), ${}, $[], backticks, process substitution <() / >(), Zsh =() expansion, PowerShell <# comments.< (other than < /dev/null which is safe). Reads from arbitrary files or process substitutions.> or >> to a non-null, non-fd target. Writing to files requires a separate permission check via checkPathConstraints.IFS= or unquoted $IFS. Changing the Internal Field Separator is a classic token-splitting attack that rewrites how subsequent word splitting works.git commit -m "msg". Allows the common pattern but bails if the message contains $(), backticks, or the remainder has shell operators./proc/*/environ or /proc/self/environ. This file contains the full environment of any running process, including secrets.hasMalformedTokens() from the shell-quote parser. Detects commands like echo {"hi":\"hi;calc.exe"} where shell-quote mis-tokenizes an unbalanced quote into a valid bash injection.tr\ace as a word looks like trase but executes as traceroute-equivalent token chains.{a,b,c} or {1..10}. Brace expansion happens before globbing and can produce argument lists invisible to static analysis.# that appears adjacent to a closing quote, like 'x'#. Without this check, quoted content can be stripped leaving # at word-start where bash treats it as a comment, hiding everything after it.zmodload, emulate, sysopen, sysread, syswrite, zpty, ztcp, zsocket, plus Zsh file builtins (zf_rm, zf_mv, etc.) that bypass the binary-command deny list.\;, \|, \& — sometimes used to smuggle operators past simple string-based detection while still being meaningful to the shell in certain contexts.# at a word boundary, creating a comment that hides a shell operator. Example: echo 'x'#dangerous_suffix.Before the main validator chain, there is a special early-allow path for the pattern cmd $(cat <<'DELIM'\n...\nDELIM\n). This is the canonical way to pass multi-line content as a commit message or script argument without triggering the command-substitution validator.
The isSafeHeredoc() function uses line-based matching, not regex, to find the closing delimiter — exactly replicating bash's own heredoc-closing behavior. Key safety conditions:
<<'EOF') or backslash-escaped (<<\EOF) so the body is literal text with no expansion.$().// Safe — ALLOWED:
git commit -m "$(cat <<'EOF'
Fix the login bug
Resolves #1234
EOF
)"
// Unsafe — BLOCKED (substitution in command-name position):
$(cat <<'EOF'
chmod
EOF
) 777 /etc/shadow
Each validator receives a ValidationContext with five pre-computed views of the command:
type ValidationContext = {
originalCommand: string // verbatim from Claude
baseCommand: string // first token after env vars
unquotedContent: string // double-quotes stripped
fullyUnquotedContent: string // both quote types stripped
fullyUnquotedPreStrip: string // before safe-redirection stripping
unquotedKeepQuoteChars: string // strips content but keeps quote delimiters
treeSitter?: TreeSitterAnalysis | null // AST, if available
}
unquotedKeepQuoteChars is specifically used by validateMidWordHash: it strips the content of quoted strings but keeps the quote characters themselves. This reveals patterns like 'x'# where x is inside single quotes and # is quote-adjacent — a situation where naive stripping would hide the # entirely.
For compound commands (cmd1 && cmd2), splitCommand_DEPRECATED splits them into individual subcommands and the validator chain runs on each one. There is a cap of 50 subcommands — beyond that the system returns ask as a safe default, because legitimate user commands almost never split that wide, and the unbounded growth triggered a real CPU-starvation DoS.
After the security validators, the tool's checkPermissions() calls bashToolHasPermission() in bashPermissions.ts. This function layers several independent checks before consulting the user's allow/deny rules.
A rule like Bash(npm run:*) should match NODE_ENV=production npm run build. To enable this, stripSafeWrappers() performs two-phase stripping before comparing against rules:
Phase 1: Strip leading env vars where the variable name is in SAFE_ENV_VARS. The allowlist covers build/locale/display vars that cannot execute code. Variables like PATH, LD_PRELOAD, PYTHONPATH are deliberately NOT in the list.
Phase 2: Strip wrapper commands: timeout (with full GNU flag parsing), time, nice (all invocation forms), nohup. Each uses a regex with an allowlisted character class for flag values — the old [^ \t]+ pattern was bypassed by timeout -k$(id) 10 ls where $(id) matched as a duration value and got stripped.
Both phases run in a fixed-point loop (repeat until stable), handling interleaved patterns like timeout 300 NODE_ENV=prod npm run build.
// SECURITY NOTE: Phase 2 does NOT strip env vars.
// After a wrapper, VAR=val is treated as the command to execute.
// `timeout 10 MY_CMD=foo` — MY_CMD=foo is the command name, not an env assignment.
// Stripping it here would create a false permission match.
Permission rules stored in settings use the format Bash(content). The content has three match modes:
| Mode | Format | Example | Matches |
|---|---|---|---|
| Exact | full command | Bash(git status) | Only git status, nothing else |
| Prefix (legacy) | cmd:* | Bash(npm run:*) | npm run + anything after |
| Wildcard | pattern with * | Bash(git * --force) | Any command matching the glob |
The getSimpleCommandPrefix() function also auto-generates 2-word prefix rules when the user approves a command. For git commit -m "fix typo", the suggestion is Bash(git commit:*), not the literal command (which would never match again). For heredoc commands, the prefix is extracted before the << operator.
Bare shell names (bash, sh, zsh, python, env, sudo, etc.) are excluded from prefix suggestions entirely — Bash(bash:*) would be equivalent to Bash(*) and approve arbitrary code execution.
shouldUseSandbox() in tools/BashTool/shouldUseSandbox.ts makes a four-way decision:
function shouldUseSandbox(input: SandboxInput): boolean {
// 1. Sandboxing must be enabled in the first place
if (!SandboxManager.isSandboxingEnabled()) return false
// 2. Explicit user override (AND policy must allow unsandboxed commands)
if (input.dangerouslyDisableSandbox && SandboxManager.areUnsandboxedCommandsAllowed())
return false
// 3. Empty command
if (!input.command) return false
// 4. User-configured excluded commands (not a security boundary — just convenience)
if (containsExcludedCommand(input.command)) return false
return true
}
The sandbox controls filesystem and network access at process-spawn time via SandboxManager. Claude is prompted about the active restrictions via the system prompt so it can adjust its behavior:
~/.ssh)$TMPDIR)sandbox.excludedCommands in settings is a convenience feature — it lets users run tools like docker or bazel without sandboxing because those tools need direct process access. But it is NOT a security control. The permission prompt system is. A comment in the source explicitly documents this to prevent misuse.
containsExcludedCommand() splits compound commands and checks each subcommand. Additionally, for each subcommand it generates a fixed-point closure of stripped forms (env vars stripped, wrappers stripped) and checks all variants against the pattern. This handles FOO=bar bazel run //... matching a bazel:* excluded pattern.
The fixed-point approach matches the stripping logic used in permission rule checking — if a pattern would match at permission-check time, it also matches at sandbox-exclusion time. Consistency prevents the situation where a command escapes the sandbox via an excluded pattern that the permission check would never match.
There are three distinct ways a Bash command ends up running in the background:
Explicit model-initiated background. The model sets this field when it doesn't need the result immediately.
Manual background. User moves a running command to background mid-execution. Sets backgroundedByUser: true.
After 15 seconds in assistant mode, a blocking command is moved to background automatically. Sets assistantAutoBackgrounded: true.
When a command is backgrounded, a backgroundTaskId is returned and output is streamed to a file at getTaskOutputPath(taskId). The model is given the output path so it can read it later via the FileRead tool.
// tools/BashTool/BashTool.tsx — assistant auto-background message
const ASSISTANT_BLOCKING_BUDGET_MS = 15_000
backgroundInfo = `Command exceeded the assistant-mode blocking budget (${ASSISTANT_BLOCKING_BUDGET_MS / 1000}s)
and was moved to the background with ID: ${backgroundTaskId}.
Output is being written to: ${outputPath}.
In assistant mode, delegate long-running work to a subagent or use run_in_background`
When the Monitor tool feature is enabled, standalone sleep N (where N ≥ 2) as the first subcommand is blocked by validateInput() before any permission check. The error message explains why and what to use instead:
function detectBlockedSleepPattern(command: string): string | null {
const m = /^sleep\s+(\d+)\s*$/.exec(first)
if (!m) return null
const secs = parseInt(m[1]!, 10)
if (secs < 2) return null // sub-2s sleeps are fine (rate limiting, pacing)
const rest = parts.slice(1).join(' ').trim()
return rest
? `sleep ${secs} followed by: ${rest}` // suggest Monitor
: `standalone sleep ${secs}` // "what are you waiting for?"
}
Float-duration sleeps (sleep 0.5) are exempt — they represent legitimate rate-limiting or deliberate pacing, not polling loops. The pattern only fires on integer durations.
// utils/shell/outputLimits.ts
export const BASH_MAX_OUTPUT_UPPER_LIMIT = 150_000 // chars — hard cap
export const BASH_MAX_OUTPUT_DEFAULT = 30_000 // chars — configurable via BASH_MAX_OUTPUT_LENGTH
When output exceeds the inline limit, the full output is persisted to a file in the tool-results directory. The model receives a <persisted-output> message containing the file path and a preview. The tool-results directory copy is capped at 64 MB via truncation before the hard link.
If stdout starts with a base64-encoded PNG or JPEG header, isImageOutput() sets isImage: true and the output is wrapped in an image content block for Claude. Large images are resized before sending to stay within content block limits.
interpretCommandResult() maps non-zero exit codes to human-readable notes using a table of well-known codes per command. For example, grep returning 1 means "no matches" (not an error), and diff returning 1 means "files differ" (not an error). These are surfaced in the UI as returnCodeInterpretation rather than treating them as failures.
CLIs and SDKs that set CLAUDECODE=1 can emit <claude-code-hint /> tags to stderr (merged into stdout). The tool scans for these tags, records them for the hint recommendation system, then strips them before the model sees the output — a zero-token side channel. Subagent outputs are also stripped so hints don't escape the agent boundary.
The UI uses command classification to decide whether to collapse tool-result messages by default (search/read commands usually produce large output that is more useful collapsed).
// tools/BashTool/BashTool.tsx — command sets for collapsible display
const BASH_SEARCH_COMMANDS = new Set(['find', 'grep', 'rg', 'ag', 'ack', ...])
const BASH_READ_COMMANDS = new Set(['cat', 'head', 'tail', 'jq', 'awk', ...])
const BASH_LIST_COMMANDS = new Set(['ls', 'tree', 'du'])
// Semantic-neutral: don't affect the read/search classification of a pipeline
const BASH_SEMANTIC_NEUTRAL_COMMANDS = new Set(['echo', 'printf', 'true', 'false', ':'])
For pipelines like cat file | jq .name, all parts must be search/read commands for the whole pipeline to be considered collapsible. A single non-search command makes the whole pipeline non-collapsible. Neutral commands (echo, true) are skipped in any position.
The BASH_SILENT_COMMANDS set (mv, cp, rm, mkdir, etc.) drives a separate decision: show "Done" in the UI instead of "(No output)" when these commands succeed with no stdout.
The sed special case: if the command matches the sed in-place edit pattern (sed -i 's/.../.../' file), the tool's userFacingName() renders it as a file-edit operation (not a bash command) using the FileEdit tool's display name. This means the user sees a file-diff-style permission dialog instead of a generic command approval.
bashSecurity.ts defend against injection attacks at every layer: character encoding (unicode whitespace, control chars), shell syntax (brace expansion, backticks, process substitution), Zsh-specific escapes, and even jq-internal functions. They form a defense-in-depth stack, not a single gate.PATH, LD_PRELOAD, and module-path variables precisely because they affect execution, not just behavior.2>&1 at the file-descriptor level. The stderr field in the output schema is always empty for normal commands. Callers that expect stderr to carry error content will be surprised.Test your understanding. Click an option to see feedback.
git commit -m "fix". What rule does Claude Code actually save?echo $'\n'evil_command?stderr field in the Bash tool's output schema is almost always empty. Why?docker to sandbox.excludedCommands. A malicious command runs echo hi && docker exec bad-container cmd. Is it excluded from the sandbox?ask above that?unquotedKeepQuoteChars view in ValidationContext?