10-BashTool-深度剖析
第 10 篇:BashTool 深度剖析 — 最复杂的单个工具
本篇是《深入 Claude Code 源码》系列第 10 篇。BashTool 是 Claude Code 中代码量最大、安全逻辑最复杂的单个工具,总计约 12,400 行代码。我们将从命令语义分析、多层安全防线、沙箱执行、输出处理、权限匹配五个维度,完整剖析它如何让 AI 安全地执行 Shell 命令。
为什么 BashTool 是最复杂的工具?
在第 9 篇中我们了解了 buildTool() 的抽象体系和 Tool 接口的设计。所有工具都遵循相同的 name / inputSchema / call() / checkPermissions() 协议。但 BashTool 的特殊之处在于:它是唯一一个允许 AI 在用户机器上执行任意代码的工具。
这意味着它必须同时解决两个相互矛盾的需求:
- 足够强大 — AI 需要通过 Shell 命令完成 git 操作、运行测试、安装依赖、查看日志等几乎一切任务
- 足够安全 — 绝不能让 AI(或通过 prompt injection 操控 AI 的攻击者)执行破坏性操作
这个矛盾造就了一个包含 18 个源码文件、总计约 12,400 行代码的庞大子系统:
| 文件 | 行数 | 职责 |
|---|---|---|
BashTool.tsx |
1,143 | 主文件:buildTool 定义、call() 执行、runShellCommand 生成器 |
bashPermissions.ts |
2,621 | 权限判定主流程、规则匹配、前缀提取 |
bashSecurity.ts |
2,592 | 安全验证:20+ 种攻击模式检测 |
readOnlyValidation.ts |
1,990 | 只读命令白名单验证(100+ 命令配置) |
pathValidation.ts |
1,303 | 路径安全校验:危险路径检测、项目边界限制 |
sedValidation.ts |
684 | sed 命令的特殊验证逻辑 |
prompt.ts |
369 | BashTool 的 System Prompt 生成 |
shouldUseSandbox.ts |
153 | 沙箱决策逻辑 |
commandSemantics.ts |
140 | 退出码语义解释 |
| 其他 8 个文件 | ~1,405 | UI 渲染、sed 编辑解析、破坏性命令警告等 |
接下来,我们沿着一条命令从 AI 生成到执行完成的完整生命周期来分析这个系统。
一、命令语义分类:AI 执行的是什么类型的命令?
BashTool 的第一个设计亮点是命令语义分类。它不是把所有命令一视同仁,而是在多个维度上对命令进行分类,每种分类决定了不同的 UI 展示和安全策略。
1.1 搜索/读取/列表分类
BashTool.tsx:59-172 定义了四组命令语义集合:
1 | // BashTool.tsx:60-72 |
isSearchOrReadBashCommand() 函数分析整个命令管道(pipeline),只有当所有非中性子命令都属于搜索/读取/列表类别时,整个命令才被标记为可折叠。这意味着 ls dir && echo "---" && ls dir2 被视为列表命令(echo 是中性的),而 ls dir && rm file 则不是。
这个分类决定了 UI 层面的展示:搜索命令折叠显示为 “Searched N files”,读取命令显示为 “Read N files”,列表命令显示为 “Listed N directories”。
1.2 静默命令分类
1 | // BashTool.tsx:81 |
静默命令成功时通常不产生 stdout。BashTool 检测到这类命令后,在 UI 上显示 “Done” 而非 “(No output)”——一个小细节,但体现了对用户体验的关注。
1.3 退出码语义解释
commandSemantics.ts 实现了一个精巧的退出码语义系统。许多命令使用非零退出码传达信息(而非错误):
1 | // commandSemantics.ts:31-48 |
没有这个系统,grep 没找到匹配返回 1 时,AI 会以为命令出错了,尝试修复一个并不存在的问题。语义解释让 AI 能准确理解命令执行的实际含义。
二、权限主链路:bashToolHasPermission 的真实执行顺序
BashTool 的安全设计是整个工具中最复杂也最精妙的部分。它采用了纵深防御(Defense in Depth)策略——不是依赖单一检查,而是在命令执行路径上设置多层防线,任何一层拦截都能阻止危险操作。
要理解 BashTool 的安全架构,必须先厘清 bashToolHasPermission()(bashPermissions.ts:1663-2400+)的真实执行顺序。这个函数是权限判定的主入口,由 checkPermissions() 直接委托调用:
1 | flowchart TD |
这里最关键的架构事实是:tree-sitter AST 解析是主入口(Step 0),不是”额外的精确分析层”。只有当 tree-sitter WASM 不可用、或被 killswitch 关闭时,才回退到 bashSecurity.ts 的正则路径。而只读验证(checkReadOnlyConstraints)不在主链路中——它通过 BashTool.isReadOnly() 被每个子命令的 bashToolCheckPermission() 在步骤 7 调用(在 deny/ask/allow 规则和路径约束之后),而非作为独立的”第 4 层”。
2.1 第一层:输入验证(validateInput)
最简单的一层,检查命令的基本合法性:
1 | // BashTool.tsx:524-538 |
detectBlockedSleepPattern()(BashTool.tsx:322-337)检测 sleep N(N≥2)开头的命令。低于 2 秒的 sleep(用于速率限制)被放行,而 sleep 5 && check 这样的轮询模式会被建议改用 Monitor 工具或 run_in_background。
2.2 AST 解析:主入口的 fail-closed 设计(ast.ts)
bashToolHasPermission() 的第一步(Step 0,bashPermissions.ts:1670-1806)就是 tree-sitter AST 解析。这不是”额外的精确分析层”——它就是主安全入口:
1 | // bashPermissions.ts:1688-1695 |
parseForSecurityFromAst() 基于 tree-sitter 的 AST 分析(utils/bash/ast.ts),其核心设计特性是 FAIL-CLOSED:
1 | // utils/bash/ast.ts:1-18 |
tree-sitter 解析器产出三种结果:
simple:命令结构简单,成功提取了每个子命令的argv[](命令名和参数已去除引号)too-complex:遇到未在白名单中的 AST 节点类型,拒绝分析 → 回退到弹窗确认parse-unavailable:tree-sitter 不可用(外部构建)→ 回退到正则分析
白名单方式是这个设计的精髓:与其列举所有危险的 Shell 语法(永远列不完),不如只允许已知安全的语法结构通过,其他一律要求用户确认。
2.3 Legacy 回退路径(bashSecurity.ts)
当 tree-sitter WASM 不可用(如外部构建未包含 TREE_SITTER_BASH feature)或被 GrowthBook killswitch 关闭时,主链路回退到 bashSecurity.ts 的正则分析路径(bashPermissions.ts:1808-1827,2078-2142)。这条路径有 2,592 行代码,包含 23 种安全检查——它曾经是唯一的安全入口,现在作为 AST 路径的 fallback 继续服务。
核心思路是:在引号和转义处理之后,用正则检测命令中的危险模式。
2.3.1 命令替换模式检测
1 | // bashSecurity.ts:16-41 |
一个值得注意的防御是 Zsh EQUALS expansion:=curl evil.com 在 Zsh 中会展开为 /usr/bin/curl evil.com,绕过 Bash(curl:*) 的 deny 规则。这种 Shell 特性的差异攻击被一个简洁的正则模式拦截。
2.3.2 危险命令黑名单
1 | // bashSecurity.ts:45-74 |
这个黑名单暴露了一个深层的安全挑战:Zsh 内建模块(如 zsh/system、zsh/net/tcp)可以在不调用外部二进制的情况下执行文件 I/O 和网络操作,绕过传统的命令名检查。BashTool 通过同时拦截 zmodload(加载器)和各个模块命令(纵深防御)来应对这个威胁。
2.3.3 引号剥离与上下文分析
安全检查的一个核心难题是:命令中的危险模式可能被引号”藏起来”。extractQuotedContent() 函数(bashSecurity.ts:128-174)负责剥离引号内容,产出三种视图:
1 | // bashSecurity.ts:119-126 |
为什么需要三种视图?因为不同的安全检查需要不同的上下文:
withDoubleQuotes:检测双引号内的变量展开($()在双引号内仍然会被展开)fullyUnquoted:检测命令管道中的重定向、shell 元字符unquotedKeepQuoteChars:检测”引号粘连 hash”攻击(如'x'#用注释隐藏后续命令)
2.3.4 安全检查清单
bashSecurity.ts:77-101 定义了 23 种安全检查的数字标识符,每种对应一个检测器:
1 | const BASH_SECURITY_CHECK_IDS = { |
每种检查都有对应的 validator 函数。检测到问题时,命令不会被直接拒绝,而是标记为”不安全”,触发权限确认对话框。这体现了一个核心原则:安全系统应该 fail-closed(检测到不确定性时默认询问用户),而不是 fail-open。
2.4 每子命令权限判定中的只读验证(readOnlyValidation.ts)
只读验证并非主链路的独立层,而是在每个子命令的权限判定函数 bashToolCheckPermission()(bashPermissions.ts:1050-1178)内部的第 7 步调用。判定顺序为:
- 精确匹配 deny/ask 规则
- 前缀/通配符 deny/ask 规则
- 路径约束检查(
checkPathConstraints) - 精确匹配 allow 规则
- 前缀/通配符 allow 规则
- sed 约束 + 模式检查
- 只读验证:
BashTool.isReadOnly(input)→checkReadOnlyConstraints()
只有当前面所有规则都没有匹配时,才轮到只读验证。这意味着如果用户设置了 Bash(git status) 的 deny 规则,即使 git status 是只读命令,也会被拒绝——deny 规则的优先级高于只读放行。
readOnlyValidation.ts 长达 1,990 行,其中大部分是命令配置。它为 100+ 个常用命令定义了”安全标志白名单”。但在判定一条命令是否只读之前,checkReadOnlyConstraints() 还有一串关键的安全前置条件(readOnlyValidation.ts:1882-1966):
1 | // readOnlyValidation.ts:34-49 (简化展示) |
以 fd(文件搜索工具)的安全标志配置为例:
1 | // readOnlyValidation.ts:55-100 (部分) |
注意注释中的安全考量:fd -x 虽然是一个”搜索工具的标志”,但它允许对搜索结果执行任意命令,所以被排除在白名单外。这种粒度的安全分析在每个命令上都有体现。
checkReadOnlyConstraints() 的整体逻辑是:
安全前置条件(readOnlyValidation.ts:1882-1966,任何一项不满足则返回 passthrough,不做只读放行):
- 命令可被
shell-quote解析 bashCommandIsSafe_DEPRECATED()通过(无危险模式)- 不含 Windows UNC 路径(防 WebDAV 攻击)
- 不含
cd+git组合(防 bare repo hook 攻击) - 不在 bare repository 结构的目录中运行 git(防恶意 hooks)
- 不在 git 内部路径写入后运行 git(防
mkdir hooks && echo evil > hooks/pre-commit && git status) - 沙箱启用时,不在 original CWD 之外运行 git(防竞态条件:后台命令在子目录创建 bare repo 文件)
标志白名单验证(通过前置条件后):
- 拆分复合命令(
&&、||、|) - 对每个子命令:提取基础命令名 → 查找 CommandConfig → 验证所有标志都在白名单中
- 所有子命令都通过只读验证 → 整个命令被标记为只读,跳过权限确认
三、权限判定:规则匹配与智能建议
当命令通过了安全分析但不是只读命令时,进入权限判定流程。bashPermissions.ts(2,621 行)实现了一套精密的权限规则匹配系统。
3.1 权限规则的三种形态
1 | // bashPermissions.ts (通过 shellRuleMatching.ts) |
权限规则有三种来源:alwaysAllowRules(自动批准)、alwaysDenyRules(自动拒绝)、alwaysAskRules(总是询问)。规则按 source 分层(参见第 16 篇权限系统)。
3.2 环境变量剥离与包装器剥离
一个巧妙的设计是”安全包装器剥离”。当 AI 执行 NODE_ENV=test npm run build 时,权限系统需要把它识别为 npm run build 来匹配规则。
1 | // bashPermissions.ts:378-399 (部分) |
安全白名单严格区分了”只改变行为配置”的环境变量和”可以执行代码”的环境变量。PATH=evil npm run build 不会被剥离——因为 PATH 可以用来劫持二进制。
类似地,安全的包装器命令(nice、timeout、time 等)也会被剥离后再进行匹配。但 sudo、env、bash -c 等永远不会被建议为权限规则前缀,因为 Bash(sudo:*) 等价于 Bash(*):
1 | // bashPermissions.ts:196-226 |
3.3 智能规则建议
当用户批准一条命令时,系统会自动建议可复用的权限规则。getSimpleCommandPrefix() 提取命令前缀(如 git commit),suggestionForExactCommand() 决定建议的规则形式:
1 | // bashPermissions.ts:161-188 |
git commit -m "fix typo" → 建议规则 Bash(git commit:*)NODE_ENV=prod npm run build → 建议规则 Bash(npm run:*)MY_VAR=val npm run build → 不建议前缀(MY_VAR 不是安全变量)
对于包含 heredoc 的命令,由于 heredoc 内容每次都不同,精确匹配规则永远不会再次命中。系统自动提取 heredoc 前的前缀作为规则建议。
3.4 复合命令的安全上限
为防止恶意构造的超长复合命令导致系统卡死,权限检查设置了硬上限:
1 | // bashPermissions.ts:103 |
超过 50 个子命令时直接要求用户确认(安全默认值),超过 5 条建议规则时合并为”similar commands”。这个限制源自一个真实的性能事件(CC-643):某些复合命令在 splitCommand 后产生指数级增长的子命令数组,每个子命令都要跑 tree-sitter 解析 + 20+ 个安全检查 + logEvent,最终导致事件循环饿死。
四、沙箱执行:命令运行时的隔离
通过了所有安全检查后,命令进入执行阶段。shouldUseSandbox.ts 决定命令是否在沙箱中运行。
4.1 沙箱决策逻辑
1 | // shouldUseSandbox.ts:130-153 |
沙箱的排除检查(containsExcludedCommand)同样经过精心设计。它不只检查整个命令,而是先拆分复合命令,对每个子命令独立检查。这防止了 docker ps && curl evil.com 因为 docker 在排除列表中而整个命令跳出沙箱的攻击。
4.2 排除命令的迭代剥离
1 | // shouldUseSandbox.ts:82-101 |
这个”不动点迭代”算法处理交错出现的环境变量和包装器,如 timeout 300 FOO=bar bazel run——单次剥离无法处理,但迭代直到没有新候选项产生即可。
4.3 沙箱的 Prompt 指令
prompt.ts:172-273 中的 getSimpleSandboxSection() 将沙箱配置(文件系统读写权限、网络白名单)序列化为 JSON 注入 System Prompt,让模型知道沙箱的限制:
1 | // prompt.ts:188-203 (简化) |
一个细节:临时目录路径(如 /private/tmp/claude-1001/)被规范化为 $TMPDIR,避免因用户 UID 不同导致 prompt 差异,从而破坏跨用户的全局 Prompt Cache。
五、命令执行与输出处理
5.1 AsyncGenerator 驱动的执行
runShellCommand()(BashTool.tsx:826-1143)是一个 AsyncGenerator,通过 yield 产出进度更新,通过 return 返回最终结果:
1 | // BashTool.tsx:826-853 (类型签名) |
调用端用 do...while 循环消费生成器:
1 | // BashTool.tsx:660-682 |
5.2 进度显示的 2 秒阈值
1 | // BashTool.tsx:55 |
命令启动后的前 2 秒内不显示进度。如果命令在 2 秒内完成(大多数情况),用户看到的是即时结果,没有闪烁的进度条。只有超过 2 秒的命令才显示实时输出:
1 | // BashTool.tsx:1006-1014 |
5.3 前台/后台任务转换
BashTool 支持三种后台化方式:
- AI 主动后台化:
run_in_background: true(BashTool.tsx:989-1001) - 超时自动后台化:超过默认超时时间后自动转入后台(BashTool.tsx:967-971)
- 用户手动后台化:Ctrl+B 将正在运行的前台命令转为后台(UI.tsx:31-84)
- Assistant 模式自动后台化:主 agent 超过 15 秒的阻塞命令自动后台化(BashTool.tsx:976-983)
1 | // BashTool.tsx:57 |
5.4 大输出的持久化处理
BashTool 的输出持久化有两个独立的机制:
机制一:Shell 层面的文件模式输出。 当 Shell 命令的 stdout 超过一定大小时,TaskOutput 会将输出写入磁盘文件而非全部保存在内存中。命令执行完成后,BashTool 检测到 result.outputFilePath 存在时,将输出文件硬链接(失败则复制)到 tool-results/ 目录,并生成 <persisted-output> 格式的预览供模型读取:
1 | // BashTool.tsx:732 |
机制二:通用 tool_result 持久化阈值。 maxResultSizeChars: 30_000 是 Tool 接口的通用机制(定义在 Tool.ts),当工具返回的结果文本超过此阈值时,由框架层将结果持久化到磁盘。BashTool 将此阈值设为 30K(其他工具默认 100K),因为 Shell 输出通常较长。
这两个机制是互补的:Shell 层面的文件模式处理的是执行期间的大量输出流,而 maxResultSizeChars 处理的是结果返回时的大小控制。
5.5 图片输出检测
1 | // BashTool.tsx:785-802 |
当命令输出是 base64 编码的图片时(如截图工具),BashTool 检测到后将其作为 image content block 发送给模型,让 Claude 能”看到”图片内容。过大的图片会被压缩或回退为文本。
六、Prompt 工程:引导 AI 正确使用 BashTool
prompt.ts 生成的 BashTool 描述不仅告诉模型这个工具能做什么,还包含了大量行为引导指令。
6.1 工具偏好引导
1 | // prompt.ts:280-291 |
这些指令引导模型优先使用专用工具(Glob、Grep、FileRead 等)而非通过 BashTool 调用对应的命令行工具。原因是专用工具有更好的 UI 呈现和权限控制。
6.2 Git 安全协议
prompt.ts:81-161 包含了完整的 Git Safety Protocol,直接嵌入 BashTool 的 System Prompt:
- 永远不要更新 git config
- 永远不要跳过 hooks(
--no-verify) - 永远不要 force push 到 main/master
- 永远创建 NEW commit 而不是 amend(pre-commit hook 失败后 amend 会修改前一个 commit)
- 用 HEREDOC 格式传递 commit message(确保正确格式化)
6.3 Input Schema 的安全设计
1 | // BashTool.tsx:227-259 |
_simulatedSedEdit 是一个内部字段,用于 sed 编辑预览后的精确写入。它被从模型可见的 schema 中移除,因为暴露它会让模型绕过权限检查和沙箱——模型可以发送一个无害的 command 配合任意 _simulatedSedEdit 来写入任何文件。
七、特殊处理:sed 编辑的文件编辑化
BashTool 对 sed -i 命令有一套完整的特殊处理流程。sedEditParser.ts(322 行)将 sed 命令解析为结构化的编辑操作:
1 | // sedEditParser.ts:23-33 |
当检测到 sed -i 's/old/new/g' file.txt 时:
- UI 渲染:不显示 sed 命令,而是像 FileEditTool 一样显示文件路径(UI.tsx:99-103)
- 权限预览:在权限确认对话框中显示文件 diff 预览
- 精确写入:用户确认后,不执行 sed 命令,而是通过
applySedEdit()直接写入预览内容(BashTool.tsx:360-419),确保用户看到什么就写入什么
这个”模拟执行”模式(_simulatedSedEdit)解决了一个微妙的一致性问题:如果在用户预览和实际执行之间文件被修改了,sed 命令的结果可能与预览不同。通过在预览时就计算好最终内容并保存,写入时直接使用,消除了 TOCTOU(Time-of-Check-to-Time-of-Use)竞态。
八、破坏性命令警告
destructiveCommandWarning.ts 为常见的破坏性命令提供额外的可视化警告。需要注意,这套警告机制受 GrowthBook feature flag tengu_destructive_command_warning 控制——只有当该 flag 开启时,BashPermissionRequest 组件才会调用 getDestructiveCommandWarning()(BashPermissionRequest.tsx:274):
1 | // destructiveCommandWarning.ts:12-89 (部分) |
注意 git clean 的正则排除了 --dry-run 标志——如果命令包含 dry run,就不需要警告。这种对 CLI 工具语义的精确理解贯穿了整个 BashTool 的设计。
九、可迁移的设计模式
模式 1:纵深防御架构
将安全检查分为多个独立的维度(AST 结构验证 → 语义检查 → 权限规则匹配 → 路径约束 → 只读白名单 → 沙箱),在权限主链路中按优先级编排。任何一个维度都可以独立地阻止危险操作,同时 AST 不可用时可以回退到正则路径。
适用场景:任何允许用户/AI 执行动态操作的系统。不要依赖单一的安全检查点。
模式 2:Fail-Closed 白名单模式
使用明确的白名单(而非黑名单)来定义安全行为。tree-sitter AST 分析中,只有白名单中的节点类型被允许通过,所有未知结构默认拒绝。只读命令验证中,只有白名单标志被认为安全。
适用场景:安全敏感的解析和验证场景。黑名单永远不完整(有无限种攻击方式),白名单则有有限的已知安全项。
模式 3:语义分类驱动的差异化处理
对输入进行语义分类(搜索/读取/列表/静默/破坏性),根据分类结果采取不同的 UI 展示和安全策略。这比”一刀切”的处理方式能同时提供更好的用户体验和更精确的安全控制。
适用场景:处理多种类型输入的工具系统。电商系统中的订单(预付/货到付款/退款)、日志系统中的事件(info/warn/error)等都可以从语义分类中获益。
下一篇预告
我们将深入 commands.ts,看 Claude Code 如何将内建命令、Skills 命令、Plugin 命令和 Workflow 命令统一聚合成一个可扩展的斜杠命令体系。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)