markdown.engineering

Lesson 17 — The Bash Tool

Shell execution, snapshot environments, command building, 23 security validators, permission plumbing, sandboxing, and background tasks.

1. Architecture overview

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.

flowchart TD A["Claude emits Bash(command)"] --> B["validateInput\n(sleep-pattern guard)"] B --> C["checkPermissions\nbashToolHasPermission()"] C -->|allow| D["call()"] C -->|ask| E["Permission dialog"] C -->|deny| F["Blocked"] E -->|approved| D D --> G["runShellCommand generator"] G --> H["buildExecCommand\n(snapshot + eval wrap)"] H --> I["shouldUseSandbox?\nSandboxManager.wrap"] I --> J["spawn shell process\ndetached=true"] J --> K["async output streaming\nonProgress callbacks"] K --> L["interpretCommandResult\n(semantic exit codes)"] L --> M["output persistence\n(>30KB → disk)"] M --> N["mapToolResultToToolResultBlockParam"] style A fill:#1a1a2e,stroke:#e94560,color:#e94560 style F fill:#2a0d0d,stroke:#f87171,color:#f87171 style J fill:#071a10,stroke:#34d399,color:#34d399

The five source directories that matter:

tools/BashTool/

Top-level tool definition, permission dispatch, sed-edit shim, UI classification, output shaping

tools/BashTool/bash*.ts

bashSecurity.ts — 23 validators; bashPermissions.ts — rule matching + env-var stripping

utils/bash/

AST parsing, command splitting, pipe rearrangement, heredoc handling, ShellSnapshot

utils/shell/

BashProvider — builds exec command string; output limits; shell detection; powershell path

utils/sandbox/

SandboxManager — filesystem + network policy enforcement at process-spawn time

2. Input / output schema

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 })
)
Why hide _simulatedSedEdit?

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.

Output schema

// 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()
})
stderr is merged into stdout

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.

3. Shell snapshot system

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.

flowchart LR A["Session start"] --> B["createAndSaveSnapshot(shellPath)"] B --> C["getSnapshotScript()"] C --> D["source .zshrc / .bashrc\n< /dev/null"] D --> E["Capture:\n• Functions (typeset -f)\n• Shell options (setopt)\n• Aliases (alias --)\n• PATH (process.env.PATH)\n• rg / find / grep shims"] E --> F["Write snapshot-{ts}-{rand}.sh"] F --> G["buildExecCommand()\nsource snapshot || true"] style A fill:#1a1a2e,stroke:#7ec8e3,color:#7ec8e3 style F fill:#071a10,stroke:#34d399,color:#34d399
What exactly goes into the snapshot?

The snapshot script sources the user's config file with < /dev/null (so no TTY-dependent prompts fire), then appends:

  1. Unalias everythingunalias -a 2>/dev/null || true — avoids alias "freeze" inside function definitions.
  2. User functionstypeset -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.
  3. Shell optionssetopt lines (zsh) or shopt -p + set -o on | awk lines (bash). Then forcibly enables expand_aliases.
  4. Aliasesalias -- form, stripping winpty aliases on Windows.
  5. rg shim — if system rg is absent, injects a shell function backed by Bun's embedded ripgrep using the ARGV0 dispatch trick.
  6. find / grep shims — in ant-native builds, always shadows system find/grep with embedded bfs/ugrep.
  7. PATH exportexport 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
}
What happens if snapshot creation fails?

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.

4. Command building pipeline

buildExecCommand() in utils/shell/bashProvider.ts assembles the full shell string that is actually passed to spawn(). The pipeline has six stages:

flowchart TD A["raw command string\n(from Claude)"] --> B["rewriteWindowsNullRedirect()\n2>nul → 2>/dev/null"] B --> C["shouldAddStdinRedirect()?\nappend < /dev/null"] C --> D["quoteShellCommand()\nwrap in single quotes for eval"] D --> E{"contains | pipe\nAND needs stdin redirect?"} E -->|yes| F["rearrangePipeCommand()\nmove < /dev/null to first segment"] E -->|no| G F --> G["Assemble command parts:\n1. source snapshot || true\n2. source sessionEnvScript\n3. disable extglob\n4. eval 'quoted_cmd'\n5. pwd -P >| cwd-file"] G --> H{"CLAUDE_CODE_SHELL_PREFIX\nset?"} H -->|yes| I["formatShellPrefixCommand()\nwrap entire string"] H -->|no| J["Final commandString"] I --> J style A fill:#1a1a2e,stroke:#e94560,color:#e94560 style J fill:#071a10,stroke:#34d399,color:#34d399
Why does eval need single quotes — and why does pipe rearrangement matter?

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:

  • Commands with backticks or $() — shell-quote mis-parses them
  • Commands with shell variables ($VAR) — shell-quote drops them
  • Control structures (for/while/if/case) — can't find pipe boundary
  • Shell-quote single-quote bug pattern ('\' payload '\')
  • Commands with bare newlines — shell-quote treats as whitespace
How does extglob disabling work and why?

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.

5. The 23 security validators

bashCommandIsSafe() in tools/BashTool/bashSecurity.ts runs a chain of named validators. Each validator returns one of three behaviors:

allow

Early-allow: command is safe, skip all remaining validators

passthrough

No opinion — continue to next validator in chain

ask

Trigger a permission dialog — the command may be dangerous

The validators run in order. The first non-passthrough result wins.

#1
INCOMPLETE_COMMANDS
Starts with tab, leading flag (-rf), or continuation operator (&&, ;). Catches command fragments that only make sense as a second half of something.
#2
JQ_SYSTEM_FUNCTION
Detects jq with env, path, builtins, modulemeta, debug — jq intrinsics that can leak the runtime environment or trigger RCE via jq modules.
#3
JQ_FILE_ARGUMENTS
Detects jq --args, --jsonargs, --rawfile, --slurpfile, or --arg patterns that could read arbitrary files or inject shell arguments.
#4
OBFUSCATED_FLAGS
Flags with encoded characters or unusual spacing that would differ between what a validator sees and what the shell executes.
#5
SHELL_METACHARACTERS
Unquoted ;, &&, ||, | — compound commands. This is the primary multi-command junction check. Most interesting permission prompts come from here.
#6
DANGEROUS_VARIABLES
References to $BASH_ENV, $ENV, $CDPATH, $IFS — shell variables that affect global shell behavior and can be weaponized to reroute execution.
#7
NEWLINES
Unquoted newlines in the command string. A newline is a command separator identical to ; in bash, making it a common injection vector that bypasses naive ;-only checks.
#8
DANGEROUS_PATTERNS — command substitution
Unquoted $(), ${}, $[], backticks, process substitution <() / >(), Zsh =() expansion, PowerShell <# comments.
#9
DANGEROUS_PATTERNS — input redirection
Unquoted < (other than < /dev/null which is safe). Reads from arbitrary files or process substitutions.
#10
DANGEROUS_PATTERNS — output redirection
Unquoted > or >> to a non-null, non-fd target. Writing to files requires a separate permission check via checkPathConstraints.
#11
IFS_INJECTION
Assignment IFS= or unquoted $IFS. Changing the Internal Field Separator is a classic token-splitting attack that rewrites how subsequent word splitting works.
#12
GIT_COMMIT_SUBSTITUTION
A special early-allow validator for git commit -m "msg". Allows the common pattern but bails if the message contains $(), backticks, or the remainder has shell operators.
#13
PROC_ENVIRON_ACCESS
References to /proc/*/environ or /proc/self/environ. This file contains the full environment of any running process, including secrets.
#14
MALFORMED_TOKEN_INJECTION
Uses 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.
#15
BACKSLASH_ESCAPED_WHITESPACE
Backslash-escaped spaces or tabs used to smuggle whitespace into a token, changing how word splitting works. tr\ace as a word looks like trase but executes as traceroute-equivalent token chains.
#16
BRACE_EXPANSION
Unquoted brace expansion {a,b,c} or {1..10}. Brace expansion happens before globbing and can produce argument lists invisible to static analysis.
#17
CONTROL_CHARACTERS
Raw ASCII control characters (\x00–\x1f except tab/newline) in the command string. These are invisible in the UI and can confuse terminal parsing or inject into log files.
#18
UNICODE_WHITESPACE
Non-ASCII whitespace (NBSP \u00A0, em-space, zero-width space, etc.) that bash treats as a word boundary but looks like nothing in a UI. Classic invisible-injection vector.
#19
MID_WORD_HASH
A # 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.
#20
ZSH_DANGEROUS_COMMANDS
Base commands: zmodload, emulate, sysopen, sysread, syswrite, zpty, ztcp, zsocket, plus Zsh file builtins (zf_rm, zf_mv, etc.) that bypass the binary-command deny list.
#21
BACKSLASH_ESCAPED_OPERATORS
Backslash before a shell operator like \;, \|, \& — sometimes used to smuggle operators past simple string-based detection while still being meaningful to the shell in certain contexts.
#22
COMMENT_QUOTE_DESYNC
Commands where quote-stripping places a # at a word boundary, creating a comment that hides a shell operator. Example: echo 'x'#dangerous_suffix.
#23
QUOTED_NEWLINE
A literal newline inside a quoted string. Though syntactically valid in bash, this is an unusual pattern that can indicate injected multi-line payloads assembled inside a quoted argument.
The safe heredoc early-allow path

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:

  1. The delimiter must be single-quoted (<<'EOF') or backslash-escaped (<<\EOF) so the body is literal text with no expansion.
  2. The closing delimiter must be on a line by itself.
  3. The substitution must appear in argument position, not command-name position (there must be a non-whitespace prefix before the $().
  4. The text remaining after stripping the heredoc must contain only safe characters (no shell metacharacters).
  5. Nested heredoc matches are rejected — they indicate the outer heredoc's literal body contains what looks like another heredoc, which our regex can't safely distinguish.
// 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
How the ValidationContext is built

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.

Security: these validators run on EVERY subcommand

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.

6. Permission plumbing

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.

flowchart TD A["bashToolHasPermission(input, context)"] --> B["checkPermissionMode()\nread-only mode?"] B -->|deny| Z1["deny"] B -->|pass| C["bashCommandIsSafe()\n23 validators"] C -->|ask| Z2["ask — security concern"] C -->|allow| D["checkReadOnlyConstraints()"] C -->|pass| D D -->|deny| Z3["deny — read-only violation"] D -->|pass| E["checkPathConstraints()\npath allowlist / denylist"] E -->|deny| Z4["deny — path out-of-scope"] E -->|pass| F["checkSedConstraints()\nsed -i detection"] F -->|ask| Z5["ask — sed preview dialog"] F -->|pass| G["checkCommandOperatorPermissions()\nper-subcommand rule matching"] G -->|allow| Z6["allow"] G -->|ask| Z7["ask — needs user rule"] G -->|deny| Z8["deny — explicit deny rule"] style Z6 fill:#071a10,stroke:#34d399,color:#34d399 style Z1 fill:#2a0d0d,stroke:#f87171,color:#f87171 style Z8 fill:#2a0d0d,stroke:#f87171,color:#f87171
Environment variable stripping before rule matching

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.
Rule matching: exact, prefix, wildcard

Permission rules stored in settings use the format Bash(content). The content has three match modes:

ModeFormatExampleMatches
Exactfull commandBash(git status)Only git status, nothing else
Prefix (legacy)cmd:*Bash(npm run:*)npm run + anything after
Wildcardpattern 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.

7. Sandboxing

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:

  • Filesystem read: deny-only list (e.g., ~/.ssh)
  • Filesystem write: allow-only list (e.g., project dir + $TMPDIR)
  • Network: optional allowedHosts / deniedHosts
excludedCommands is not a security boundary

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.

How compound commands are checked against excludedCommands

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.

8. Background execution

There are three distinct ways a Bash command ends up running in the background:

run_in_background: true

Explicit model-initiated background. The model sets this field when it doesn't need the result immediately.

User presses Ctrl+B

Manual background. User moves a running command to background mid-execution. Sets backgroundedByUser: true.

Auto-background (assistant mode)

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`
The sleep blocker pattern

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.

9. Output handling

Output size limits

// 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.

Image detection and resizing

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.

Semantic exit code interpretation

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.

Claude Code hints protocol

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.

10. UI classification

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.

Key takeaways

What to remember from this lesson

  • The shell snapshot runs once per session and captures aliases, functions, options, and PATH. Every command sources this snapshot, giving it the user's full interactive environment without spawning a login shell each time.
  • The command building pipeline has six stages: Windows-null rewrite, stdin redirect decision, single-quote eval wrapping, pipe rearrangement (with stdin fix), snapshot sourcing, extglob disabling. Each stage has a specific security or correctness reason.
  • The 23 validators in 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.
  • The safe heredoc early-allow path is a positive assertion — it short-circuits all validators. It therefore has stricter conditions than any individual validator: line-based delimiter matching, argument-position requirement, no nested heredocs, and a final check that the remainder passes all validators anyway.
  • Environment variable stripping before rule matching is a security-sensitive allowlist. The safe list omits PATH, LD_PRELOAD, and module-path variables precisely because they affect execution, not just behavior.
  • stderr is merged into stdout via 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.
  • Sandbox exclusions are not security controls — they are convenience features. The permission dialog is the actual security control. The source comments explicitly document this distinction.
  • Background execution has three paths (explicit, user-initiated, auto after 15s) with distinguishable flags in the output. The model's auto-background path only fires in assistant mode, keeping interactive sessions responsive.
  • The max-subcommand cap of 50 is a real production fix for a CPU-starvation DoS from exponentially growing subcommand arrays in complex compound commands.

Quiz

Test your understanding. Click an option to see feedback.

1. You approve a rule for git commit -m "fix". What rule does Claude Code actually save?
A Bash(git commit -m "fix") — exact match for that specific commit message
B Bash(git commit:*) — prefix rule covering all git commit invocations
C Bash(git:*) — all git commands
D Bash(*) — all bash commands
2. The shell snapshot captures the user's environment. If the snapshot file is deleted mid-session by OS tmpdir cleanup, what happens?
A The next command fails with an error because source snapshot || true exits non-zero
B The tool creates a new snapshot before executing the next command
C The session falls back to a login shell (-l flag) for remaining commands — no user aliases or functions, but commands still run
D The session terminates and the user must restart Claude Code
3. Which validator would catch the command echo $'\n'evil_command?
A NEWLINES (#7) — detects the literal \n in the string
B SHELL_METACHARACTERS (#5) — the $ sign triggers it
C DANGEROUS_PATTERNS — command substitution (#8) — $'...' is ANSI-C quoting, a form of substitution expansion
D CONTROL_CHARACTERS (#17) — the backslash is a control character
4. The stderr field in the Bash tool's output schema is almost always empty. Why?
A Claude Code suppresses stderr to avoid leaking sensitive error messages
B The shell provider merges stderr into stdout at the file-descriptor level (2>&1), so both streams arrive in stdout
C Only failed commands produce stderr output, and those are handled as ShellError exceptions
D The Zod schema makes stderr optional, so the runtime skips it for performance
5. You add docker to sandbox.excludedCommands. A malicious command runs echo hi && docker exec bad-container cmd. Is it excluded from the sandbox?
A No — sandbox.excludedCommands only matches the first word of the full command string
B Yes — containsExcludedCommand() splits compound commands and checks each subcommand, so the docker subcommand matches and the whole compound is excluded
C It depends on whether sandbox policy allows dangerouslyDisableSandbox
D No — echo is not excluded, and the first subcommand determines sandbox status
6. Why does the validator chain cap compound commands at 50 subcommands and return ask above that?
A To prevent Claude from running too many commands at once, which could overwhelm the filesystem
B The Zod validation schema only supports up to 50 command tokens
C A real production DoS — complex compound commands caused exponential subcommand array growth, starving the event loop at 100% CPU with no epoll_wait
D Bash does not support more than 50 commands chained with && or ;
7. What is the purpose of the unquotedKeepQuoteChars view in ValidationContext?
A It is used to check if the command contains a properly balanced set of quotes before validation
B It provides the raw command string with all escape characters preserved for regex matching
C It strips quoted content but keeps quote delimiters, so validateMidWordHash can detect quote-adjacent # characters like 'x'# that naive stripping would hide
D It is the version of the command passed to Tree-sitter for AST analysis