Book 第六章:BashTool 为什么这么厚
第三部分:本地执行面

第六章:BashTool 为什么这么厚

拆 BashTool 怎么把命令协议、安全分析、权限、sandbox 和宿主联动揉成一个工具。

1. 为什么要单独拆它

在 Claude Code 里,BashTool 不是“顺手包了一层 shell”。

它其实把下面这些东西全揉在了一起:

  • shell 命令协议
  • 命令安全分析
  • 权限规则匹配
  • sandbox 决策
  • 后台任务管理
  • 流式进度上报
  • 大输出持久化
  • 模型侧结果回写

所以如果只把它理解成:

run(command) -> stdout/stderr

那基本等于没看懂这部分设计。

2. 先说结论

我对 BashTool 的判断是:

它本质上是 Claude Code 里的一个“命令执行子系统”,不是普通工具。

它至少有五层:

  1. prompt.ts 约束模型什么时候该用 Bash,什么时候不该用。
  2. bashPermissions.ts 处理规则、语义分析、compound command、classifier、建议规则。
  3. readOnlyValidation.ts + pathValidation.ts + modeValidation.ts + shouldUseSandbox.ts 处理只读判定、路径风险、模式特判、sandbox 路由。
  4. BashTool.tsx 处理执行、进度、后台任务、大输出、图片输出。
  5. UI.tsx 处理终端渲染。

也就是说,Claude Code 并没有把“Bash 的复杂性”交给 shell 本身,而是自己在外面又包了一层完整 runtime。

3. 总体图

flowchart TD
    A["模型想执行命令"] --> B["prompt.ts<br/>先告诉模型 Bash 该怎么用"]
    B --> C["BashTool.tsx.validateInput"]
    C --> D["BashTool.tsx.checkPermissions"]
    D --> E["bashPermissions.ts<br/>主权限编排"]
    E --> F["readOnlyValidation.ts"]
    E --> G["pathValidation.ts"]
    E --> H["modeValidation.ts"]
    E --> I["shouldUseSandbox.ts"]
    D --> J{"allow / ask / deny"}
    J -- "allow" --> K["BashTool.tsx.call"]
    K --> L["runShellCommand()<br/>AsyncGenerator"]
    L --> M["前台执行 / 进度上报"]
    L --> N["显式后台化 / 自动后台化"]
    M --> O["大输出持久化 / 图片压缩 / cwd 修正"]
    N --> O
    O --> P["mapToolResultToToolResultBlockParam"]
    P --> Q["query.ts 下一轮"]

4. 它先用 prompt 把模型“管住”

源码锚点:

  • src/tools/BashTool/prompt.ts:172
  • src/tools/BashTool/prompt.ts:231
  • src/tools/BashTool/prompt.ts:245
  • src/tools/BashTool/prompt.ts:260
  • src/tools/BashTool/prompt.ts:275
  • src/tools/BashTool/prompt.ts:287
  • src/tools/BashTool/prompt.ts:317

很多人看工具时只盯 call(),但 Claude Code 这里有一个很明显的设计习惯:

先用 prompt 把模型行为约束住,再用 runtime 做兜底。

BashTool 的 prompt 里至少做了三件事。

4.1 把 Bash 和别的工具切开

它会明确告诉模型:

  • 读文件优先用 Read
  • 改文件优先用 Edit
  • 写文件优先用 Write
  • 不要拿 cat / sed / awk / echo 去替代这些工具

这很重要,因为作者不想让 Bash 变成“万能降级通道”。

4.2 先在提示词里约束 sandbox 心智

getSimpleSandboxSection() 里写得很明确:

  • 默认必须优先跑 sandbox
  • 只有看到明确的 sandbox 失败证据,或者用户明确要求,才考虑 dangerouslyDisableSandbox: true
  • 即使刚刚绕过过 sandbox,下一条命令也要重新默认回 sandbox
  • 临时文件要用 $TMPDIR,不要直接写 /tmp

这不是安全边界本身,但它能明显降低模型走偏的概率。

4.3 提前教模型怎么做后台任务

prompt 里还会讲:

  • 长任务要用 run_in_background
  • 不要用 sleep 轮询
  • 已经后台化的任务会通知,不要自己再 poll

这说明作者不是把后台任务当“实现细节”,而是把它当成 BashTool 协议的一部分。

5. BashTool.tsx 暴露出来的,不是一个轻接口

源码锚点:

  • src/tools/BashTool/BashTool.tsx:420
  • src/tools/BashTool/BashTool.tsx:434
  • src/tools/BashTool/BashTool.tsx:437
  • src/tools/BashTool/BashTool.tsx:445
  • src/tools/BashTool/BashTool.tsx:524
  • src/tools/BashTool/BashTool.tsx:539
  • src/tools/BashTool/BashTool.tsx:555
  • src/tools/BashTool/BashTool.tsx:624

最能说明问题的是这几个方法:

  • isConcurrencySafe(input)
  • isReadOnly(input)
  • preparePermissionMatcher(input)
  • validateInput(input)
  • checkPermissions(input, context)
  • call(input, context, ..., onProgress)
  • mapToolResultToToolResultBlockParam(output, toolUseId)

这套接口本身就在说明:

BashTool 不是“收到命令,执行一下”,而是“先分析,再决定能不能并发,再决定能不能跑,再决定怎么把结果喂回模型”。

6. 并发安全判定,其实是“只读判定”的副产物

源码锚点:

  • src/tools/BashTool/BashTool.tsx:434
  • src/tools/BashTool/BashTool.tsx:437
  • src/tools/BashTool/readOnlyValidation.ts:1876

BashTool 的并发逻辑很有代表性:

  • isConcurrencySafe(input) 不是写死的
  • 它直接依赖 isReadOnly(input)
  • isReadOnly(input) 又依赖 checkReadOnlyConstraints(...)

也就是说:

在 Claude Code 里,Bash 能不能并发,不是看它是不是 Bash,而是看这条命令在当前分析下能不能被证明为只读。

这背后其实有一套很明确的工程思路:

  • 并发不是功能问题,是风险问题
  • 不能证明只读,就不要并发

7. “只读”不是靠命令白名单硬猜,而是多层判定

源码锚点:

  • src/tools/BashTool/readOnlyValidation.ts:1876

checkReadOnlyConstraints() 的流程大致是:

  1. 先用 shell parser 看命令能不能被可靠解析
  2. 先看原始命令有没有明显危险模式
  3. 先挡掉 Windows UNC 路径
  4. 检查 compound command 里是否同时出现 cdgit
  5. 检查当前目录是不是可疑 bare git repo
  6. 检查命令是否会写 git internal paths
  7. 在 sandbox 开启时,检查 git 是否已经偏离 original cwd
  8. 最后才逐个 subcommand 判断是不是只读

也就是说,它不是:

  • ls 是只读”
  • cat 是只读”

这么一层粗判断。

而是:

先排除一批可能把“看起来只读”伪装成危险执行的场景,然后才允许把它当只读。

这点特别像 Claude Code 的整体设计风格:
先做安全收缩,再做能力放行。

8. 真正复杂的地方在 bashPermissions.ts

源码锚点:

  • src/tools/BashTool/bashPermissions.ts:991
  • src/tools/BashTool/bashPermissions.ts:1050
  • src/tools/BashTool/bashPermissions.ts:1183
  • src/tools/BashTool/bashPermissions.ts:1270
  • src/tools/BashTool/bashPermissions.ts:1459
  • src/tools/BashTool/bashPermissions.ts:1760

8.1 这不是一个简单规则匹配器

bashPermissions.ts 里同时在做:

  • exact match / prefix / wildcard 规则匹配
  • safe wrapper 和 env var 剥离
  • AST / tree-sitter 语义分析
  • compound command 拆分
  • operator 权限检查
  • classifier 接入
  • prefix rule 建议生成
  • sandbox auto-allow

所以它本质上更像:

一个命令级策略引擎。

8.2 它的主流程可以这么理解

flowchart TD
    A["原始命令"] --> B["AST / parseForSecurity"]
    B --> C["语义检查"]
    C --> D["sandbox auto-allow ?"]
    D --> E["exact rule 检查"]
    E --> F["bash prompt deny/ask classifier"]
    F --> G["operator / pipeline 检查"]
    G --> H["原始命令安全检查"]
    H --> I["拆 subcommands"]
    I --> J["cd + git / 多 cd / fanout 上限"]
    J --> K["逐 subcommand 权限检查"]
    K --> L["原始命令 redirection/path 校验"]
    L --> M["合并结果、生成建议规则、附带 pending classifier"]

这个流程里最重要的不是“有很多步骤”,而是作者在非常努力地保证两件事:

  • 不能让 compound command 把规则绕过去
  • 不能让 parser 的历史缺陷把安全检查绕过去

9. 它把规则分成了三层

源码锚点:

  • src/tools/BashTool/bashPermissions.ts:991
  • src/tools/BashTool/bashPermissions.ts:1050
  • src/tools/BashTool/bashPermissions.ts:1183

9.1 bashToolCheckExactMatchPermission

这一层只看完整命令的 exact rule:

  • exact deny
  • exact ask
  • exact allow
  • 否则 passthrough,并顺手给一个 exact suggestion

9.2 bashToolCheckPermission

这是真正的“单个 subcommand 权限判定器”,顺序大致是:

  1. exact rule
  2. prefix/wildcard deny/ask
  3. path constraints
  4. allow rule
  5. sed constraints
  6. mode-specific allow
  7. read-only allow
  8. 否则 passthrough

这里的设计味道很明显:

Claude Code 先检查显式规则,再检查路径风险,再给模式和只读做自动放行。

9.3 checkCommandAndSuggestRules

这一层是在“单个命令已经大体判完”之后,再补一件事:

  • 如果没有显式规则命中,就帮 UI 生成更合理的“别再问了”规则建议

也就是说,作者把:

  • 权限判定
  • 规则建议

拆成了两个相邻但不完全相同的阶段。

这点做得很细。

10. sandbox auto-allow 不是无脑放行

源码锚点:

  • src/tools/BashTool/bashPermissions.ts:1270
  • src/tools/BashTool/shouldUseSandbox.ts:130

这是 BashTool 里最容易被误解的一点。

表面上看:

  • sandbox 开了
  • autoAllowBashIfSandboxed 开了

好像就直接 allow 了。

但实际上,作者在 checkSandboxAutoAllow() 里先做了这些事:

  • 先看 full command 有没有显式 deny / ask
  • compound command 还要逐 subcommand 再看 deny / ask
  • deny 优先级高于 ask
  • 没有显式规则挡路,才 allow

所以这套逻辑真正表达的是:

sandbox auto-allow 只是在“没有更强显式规则反对”的前提下,给 Bash 一条快速通道。

10.1 shouldUseSandbox() 的判断也很克制

shouldUseSandbox() 本身只做几件事:

  • 全局 sandbox 没开,直接 false
  • 用户显式要求 dangerouslyDisableSandbox 且策略允许,false
  • 命中 excludedCommands,false
  • 其他情况,true

而且源码注释还明确写了:

excludedCommands 是用户便利功能,不是安全边界。

这说明作者脑子里分得很清楚:

  • 哪些是 UX 便利
  • 哪些才是真安全控制

11. validateInput() 不是 schema 校验,而是运行前治理

源码锚点:

  • src/tools/BashTool/BashTool.tsx:322
  • src/tools/BashTool/BashTool.tsx:524

validateInput() 里最典型的一条规则是:

  • 如果启用了 Monitor 能力
  • 又不是显式后台运行
  • 还写了 sleep N

那就直接拦下来。

这说明它不是只做“参数类型是否合法”的校验,而是在做:

这条命令是不是符合 Claude Code 希望的工作方式。

也就是说,validateInput() 已经开始体现产品策略了。

12. 真正执行时,它走的是一个异步生成器

源码锚点:

  • src/tools/BashTool/BashTool.tsx:624
  • src/tools/BashTool/BashTool.tsx:826
  • src/tools/BashTool/BashTool.tsx:905
  • src/tools/BashTool/BashTool.tsx:974
  • src/tools/BashTool/BashTool.tsx:1008
  • src/tools/BashTool/BashTool.tsx:1110
  • src/tools/BashTool/BashTool.tsx:1113

call() 里面不是直接 await exec(...),而是进入 runShellCommand() 这个 AsyncGenerator

这件事非常关键,因为它决定了 BashTool 的本质不是“返回一个结果”,而是“持续产生状态变化”:

  • 可能先无输出
  • 可能开始吐 progress
  • 可能前台跑着跑着进后台
  • 可能用户手动后台化
  • 可能 assistant 模式超时后自动后台化
  • 可能最后直接完成

换句话说:

BashTool 的执行模型是流式状态机,不是一次性 RPC。

13. 后台任务设计非常重

源码锚点:

  • src/tools/BashTool/BashTool.tsx:55
  • src/tools/BashTool/BashTool.tsx:57
  • src/tools/BashTool/BashTool.tsx:905
  • src/tools/BashTool/BashTool.tsx:930
  • src/tools/BashTool/BashTool.tsx:974
  • src/tools/BashTool/BashTool.tsx:982
  • src/tools/BashTool/BashTool.tsx:1113

13.1 三种后台化路径

我看到至少三条路:

  1. 模型显式传 run_in_background: true
  2. 命令超时后自动后台化
  3. assistant 模式下,超过 15s blocking budget 自动后台化

这说明作者把“后台任务”看成了一等公民,不是 shell 自己 & 的语法糖。

13.2 前台任务也会先注册成可后台化对象

在超过 PROGRESS_THRESHOLD_MS = 2000 后:

  • 它会注册 foreground task
  • UI 里显示 BackgroundHint
  • 用户可以用 Ctrl+B 把它转成后台任务

所以这里真正的抽象不是“前台 / 后台命令”,而更像:

一个 shell task,当前可能以前台或后台方式被宿主观察。

14. 输出处理也不是简单的 stdout/stderr

源码锚点:

  • src/tools/BashTool/BashTool.tsx:555
  • src/tools/BashTool/BashTool.tsx:591
  • src/tools/BashTool/BashTool.tsx:733
  • src/tools/BashTool/BashTool.tsx:749
  • src/tools/BashTool/BashTool.tsx:798
  • src/tools/BashTool/BashTool.tsx:812

BashTool 对输出做了很多次加工:

  • 大输出会持久化到磁盘,再给模型一个可读路径提示
  • 图片输出会做识别和压缩
  • sandbox 失败信息会被注释进输出
  • assistant auto background 的提示和用户手动 background 的提示不一样
  • UI 看到的结果和模型看到的 tool_result 不是一回事

这说明作者非常明确地区分了两条输出通道:

  • 给用户看的
  • 给模型继续推理看的

这个分离对 C# 版非常值得保留。

15. 我认为 BashTool 暴露了 Claude Code 的三个核心设计偏好

15.1 偏好一:尽量把“能自动证明安全”的命令自动放掉

例子:

  • 只读命令自动 allow
  • acceptEdits 模式下部分文件系统命令自动 allow
  • sandbox auto-allow 在没有显式规则冲突时直接放

15.2 偏好二:一旦无法稳定证明安全,就保守地 ask

例子:

  • AST 太复杂
  • subcommand fanout 太多
  • process substitution
  • UNC path
  • compound cd + git
  • 可疑 git internal path 写入

15.3 偏好三:长任务别卡住主 agent

例子:

  • 显式 run_in_background
  • timeout 自动后台化
  • assistant blocking budget 自动后台化
  • 前台任务可随时转后台

这三条其实正好对应 Claude Code 的三种产品目标:

  • 尽量少烦用户
  • 但别把安全边界冲穿
  • 也别把 agent 自己卡死

16. C# 版我建议怎么拆

我不建议做一个“大而全的 BashTool 类”,然后把所有逻辑全塞进去。

我更建议拆成下面几层。

16.1 权限与安全层

public interface IBashPermissionService
{
    Task<PermissionResult> CheckAsync(
        BashCommandInput input,
        RuntimeContext context,
        CancellationToken cancellationToken = default);
}

public interface ICommandSafetyAnalyzer
{
    Task<CommandSafetyResult> AnalyzeAsync(
        string command,
        CancellationToken cancellationToken = default);
}

public interface IReadOnlyCommandAnalyzer
{
    PermissionResult Check(BashCommandInput input, bool compoundCommandHasCd);
}

public interface IPathConstraintChecker
{
    PermissionResult Check(
        BashCommandInput input,
        string cwd,
        PermissionContext context,
        bool compoundCommandHasCd);
}

public interface ISandboxPolicy
{
    bool ShouldUseSandbox(BashCommandInput input, RuntimeContext context);
}

16.2 执行层

public interface IBashExecutionService
{
    IAsyncEnumerable<BashExecutionEvent> ExecuteAsync(
        BashCommandInput input,
        BashExecutionContext context,
        CancellationToken cancellationToken = default);
}

public interface IBackgroundTaskCoordinator
{
    Task<string> SpawnAsync(BashTaskSpec spec, CancellationToken cancellationToken = default);
    bool TryBackgroundForegroundTask(string taskId);
}

16.3 序列化层

public interface IBashResultSerializer
{
    ToolResultBlock SerializeForModel(BashExecutionResult result, string toolUseId);
}

16.4 聚合层

public sealed class BashToolFacade
{
    public required IBashPermissionService PermissionService { get; init; }
    public required IBashExecutionService ExecutionService { get; init; }
    public required IBashResultSerializer ResultSerializer { get; init; }
    public required ISandboxPolicy SandboxPolicy { get; init; }
}

17. 如果你要先做原型,我建议先抄这条最小闭环

最小可用版本不要一开始全抄。

先做这条链就够了:

  1. BashCommandInput
  2. IBashPermissionService 先实现 exact/prefix rule + read-only + path constraints
  3. IBashExecutionService 先支持前台执行 + progress + run_in_background
  4. IBashResultSerializer 先把大输出持久化和普通文本结果分开
  5. 再补 assistant 15s auto background

这样你能先把 BashTool 最关键的设计骨架跑起来,而不是一开始就陷进 parser 细节里。

18. 这一轮拆出来的核心结论

如果只用一句话总结这篇,那就是:

BashTool 是 Claude Code 里最像“操作系统适配层 + agent 策略层混合体”的一个工具。

它真正重要的不是“会执行 bash”,而是这几个设计:

  • 先用 prompt 管模型
  • 再用权限系统管风险
  • 再用后台任务管时延
  • 最后把结果拆成“给用户看”和“给模型继续推理”

这四件事,才是你后面做 C# 版时最值得保留的东西。

19. 下一步建议

BashTool 再往下最值得继续拆的,有两个方向:

  1. 专拆 bashPermissions.ts
    这一篇可以把 exact / prefix / compound / classifier / suggestion 彻底画成状态图。

  2. 专拆 shell 安全分析
    也就是 readOnlyValidation.ts + pathValidation.ts + bashSecurity.ts,把 Claude Code 到底在防哪些命令级绕过单独整理出来。

如果你的目标是尽快落 C#,我建议先走第 1 个,因为它更贴近运行时抽象。