来源与授权
本文来自 Claude-Code-Source-Study 原文,固定导入版本为 31b6e07a82d4。Copyright (c) 2026 Yao,依据 MIT License 提供。
本站仅按“github-public-noncommercial-2026-07-11”用于匿名、免费、非商业学习,并调整章节编排、站内链接、重复标题与阅读标记;不代表原作者对本站的合作、认证或背书。如权利方要求删除,我们会立即下架;授权或删除请求请通过本站首页底部公开联系方式联系。
本章是《深入 Claude Code 源码》系列第 11 章。BashTool 是 Claude Code 中代码量最大、安全逻辑最复杂的单个工具,总计 12,411 行代码。我们将从命令语义分析、多层安全防线、沙箱执行、输出处理、权限匹配五个维度,完整剖析它如何让 AI 安全地执行 Shell 命令;末尾再以 PowerShellTool 作为 Windows 路径上的对照实现。
为什么 BashTool 是最复杂的工具?
在第 10 章中我们了解了 buildTool() 的抽象体系和 Tool 接口的设计。所有工具都遵循相同的 name / inputSchema / call() / checkPermissions() 协议。但 BashTool 的特殊之处在于:它是唯一一个允许 AI 在用户机器上执行任意代码的工具。
这意味着它必须同时解决两个相互矛盾的需求:
- 足够强大 — AI 需要通过 Shell 命令完成 git 操作、运行测试、安装依赖、查看日志等几乎一切任务
- 足够安全 — 绝不能让 AI(或通过 prompt injection 操控 AI 的攻击者)执行破坏性操作
这个矛盾造就了一个包含 18 个源码文件、总计 12,411 行代码的庞大子系统:
| 文件 | 行数 | 职责 |
|---|---|---|
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,416 | UI 渲染、sed 编辑解析、破坏性命令警告等 |
接下来,我们沿着一条命令从 AI 生成到执行完成的完整生命周期来分析这个系统。
本章路线:第一节 命令语义分类(让 AI 知道自己在跑什么)→ 第二节 权限主链路
bashToolHasPermission→ 第三节 规则匹配与建议 → 第四节 沙箱执行 → 第五节 执行与输出处理 → 第六节 Prompt 工程 → 第七节 sed 特例 → 第八节 破坏性命令警告 → 第九节 可迁移模式 → 第十节 PowerShellTool 对照。前 5 节是主链路(分类→鉴权→规则→沙箱→执行),第六至第八节 是「围绕主链路的工程克制」,第十节 把 Windows 路径作为对照实现单列。
一、命令语义分类:AI 执行的是什么类型的命令?
BashTool 的第一个设计亮点是命令语义分类。它不是把所有命令一视同仁,而是在多个维度上对命令进行分类,每种分类决定了不同的 UI 展示和安全策略。
1.1 搜索/读取/列表分类
BashTool.tsx:59-172 定义了四组命令语义集合:
// BashTool.tsx:60-72
const BASH_SEARCH_COMMANDS = new Set([
'find', 'grep', 'rg', 'ag', 'ack', 'locate', 'which', 'whereis'
]);
const BASH_READ_COMMANDS = new Set([
'cat', 'head', 'tail', 'less', 'more',
'wc', 'stat', 'file', 'strings',
'jq', 'awk', 'cut', 'sort', 'uniq', 'tr'
]);
const BASH_LIST_COMMANDS = new Set(['ls', 'tree', 'du']);
const BASH_SEMANTIC_NEUTRAL_COMMANDS = new Set([
'echo', 'printf', 'true', 'false', ':' // bash no-op
]);
isSearchOrReadBashCommand() 函数分析整个命令管道(pipeline),只有当所有非中性子命令都属于搜索/读取/列表类别时,整个命令才被标记为可折叠。这意味着 ls dir && echo "---" && ls dir2 被视为列表命令(echo 是中性的),而 ls dir && rm file 则不是。
这个分类决定了 UI 层面的展示:搜索命令折叠显示为 "Searched N files",读取命令显示为 "Read N files",列表命令显示为 "Listed N directories"。
1.2 静默命令分类
// BashTool.tsx:81
const BASH_SILENT_COMMANDS = new Set([
'mv', 'cp', 'rm', 'mkdir', 'rmdir', 'chmod', 'chown',
'chgrp', 'touch', 'ln', 'cd', 'export', 'unset', 'wait'
]);
静默命令成功时通常不产生 stdout。BashTool 检测到这类命令后,在 UI 上显示 "Done" 而非 "(No output)"——一个小细节,但体现了对用户体验的关注。
1.3 退出码语义解释
commandSemantics.ts 实现了一个精巧的退出码语义系统。许多命令使用非零退出码传达信息(而非错误):
// commandSemantics.ts:31-48
const COMMAND_SEMANTICS: Map<string, CommandSemantic> = new Map([
// grep: 0=找到匹配, 1=未找到, 2+=真正的错误
['grep', (exitCode) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'No matches found' : undefined,
})],
// diff: 0=无差异, 1=有差异, 2+=错误
['diff', (exitCode) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'Files differ' : undefined,
})],
// test/[: 0=条件为真, 1=条件为假, 2+=错误
['test', (exitCode) => ({
isError: exitCode >= 2,
message: exitCode === 1 ? 'Condition is false' : undefined,
})],
]);
没有这个系统,grep 没找到匹配返回 1 时,AI 会以为命令出错了,尝试修复一个并不存在的问题。语义解释让 AI 能准确理解命令执行的实际含义。
二、权限主链路:bashToolHasPermission 的真实执行顺序
BashTool 的安全设计是整个工具中最复杂也最精妙的部分。它采用了纵深防御(Defense in Depth)策略——不是依赖单一检查,而是在命令执行路径上设置多层防线,任何一层拦截都能阻止危险操作。
要理解 BashTool 的安全架构,必须先厘清 bashToolHasPermission()(bashPermissions.ts:1663-2400+)的真实执行顺序。这个函数是权限判定的主入口,由 checkPermissions() 直接委托调用:
这里最关键的架构事实是:tree-sitter AST 解析是主入口(Step 0),不是"额外的精确分析层"。只有当 tree-sitter WASM 不可用、或被 killswitch 关闭时,才回退到 bashSecurity.ts 的正则路径。而只读验证(checkReadOnlyConstraints)不在主链路中——它通过 BashTool.isReadOnly() 被每个子命令的 bashToolCheckPermission() 在步骤 7 调用(在 deny/ask/allow 规则和路径约束之后),而非作为独立的"第 4 层"。
2.1 第一层:输入验证(validateInput)
最简单的一层,检查命令的基本合法性:
// BashTool.tsx:524-538
async validateInput(input: BashToolInput): Promise<ValidationResult> {
if (feature('MONITOR_TOOL') && !isBackgroundTasksDisabled
&& !input.run_in_background) {
const sleepPattern = detectBlockedSleepPattern(input.command);
if (sleepPattern !== null) {
return {
result: false,
message: `Blocked: ${sleepPattern}. Run blocking commands in the background...`,
errorCode: 10
};
}
}
return { result: true };
}
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 解析。这不是"额外的精确分析层"——它就是主安全入口:
// bashPermissions.ts:1688-1695
let astRoot = injectionCheckDisabled
? null
: feature('TREE_SITTER_BASH_SHADOW') && !shadowEnabled
? null
: await parseCommandRaw(input.command);
let astResult: ParseForSecurityResult = astRoot
? parseForSecurityFromAst(input.command, astRoot)
: { kind: 'parse-unavailable' };
parseForSecurityFromAst() 基于 tree-sitter 的 AST 分析(utils/bash/ast.ts),其核心设计特性是 FAIL-CLOSED:
// utils/bash/ast.ts:1-18
/**
* AST-based bash command analysis using tree-sitter.
*
* 关键设计特性是 FAIL-CLOSED:我们永远不解释我们不理解的结构。
* 如果 tree-sitter 生成了我们未明确允许的节点类型,
* 我们拒绝提取 argv,调用者必须询问用户。
*/
export type ParseForSecurityResult =
| { kind: 'simple'; commands: SimpleCommand[] }
| { kind: 'too-complex'; reason: string; nodeType?: string }
| { kind: 'parse-unavailable' }
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 命令替换模式检测
// bashSecurity.ts:16-41
const COMMAND_SUBSTITUTION_PATTERNS = [
{ pattern: /<\(/, message: 'process substitution <()' },
{ pattern: />\(/, message: 'process substitution >()' },
{ pattern: /=\(/, message: 'Zsh process substitution =()' },
{ pattern: /(?:^|[\s;&|])=[a-zA-Z_]/, message: 'Zsh equals expansion (=cmd)' },
{ pattern: /\$\(/, message: '$() command substitution' },
{ pattern: /\$\{/, message: '${} parameter substitution' },
{ pattern: /\$\[/, message: '$[] legacy arithmetic expansion' },
{ pattern: /<#/, message: 'PowerShell comment syntax' },
// ... 更多模式
];
一个值得注意的防御是 Zsh EQUALS expansion:=curl evil.com 在 Zsh 中会展开为 /usr/bin/curl evil.com,绕过 Bash(curl:*) 的 deny 规则。这种 Shell 特性的差异攻击被一个简洁的正则模式拦截。
2.3.2 危险命令黑名单
// bashSecurity.ts:45-74
const ZSH_DANGEROUS_COMMANDS = new Set([
'zmodload', // 加载危险模块(mapfile/sysopen/zpty/ztcp)
'emulate', // -c 标志等价于 eval
'sysopen', 'sysread', 'syswrite', 'sysseek', // 文件描述符操作
'zpty', // 伪终端命令执行
'ztcp', // TCP 连接(用于数据外泄)
'zsocket', // Unix/TCP socket
'zf_rm', 'zf_mv', 'zf_ln', 'zf_chmod', // 绕过二进制检查的内建命令
// ...
]);
这个黑名单暴露了一个深层的安全挑战:Zsh 内建模块(如 zsh/system、zsh/net/tcp)可以在不调用外部二进制的情况下执行文件 I/O 和网络操作,绕过传统的命令名检查。BashTool 通过同时拦截 zmodload(加载器)和各个模块命令(纵深防御)来应对这个威胁。
2.3.3 引号剥离与上下文分析
安全检查的一个核心难题是:命令中的危险模式可能被引号"藏起来"。extractQuotedContent() 函数(bashSecurity.ts:128-174)负责剥离引号内容,产出三种视图:
// bashSecurity.ts:119-126
type QuoteExtraction = {
withDoubleQuotes: string // 保留双引号内容,剥离单引号
fullyUnquoted: string // 完全剥离所有引号内容
unquotedKeepQuoteChars: string // 剥离内容但保留引号字符本身
}
为什么需要三种视图?因为不同的安全检查需要不同的上下文:
withDoubleQuotes:检测双引号内的变量展开($()在双引号内仍然会被展开)fullyUnquoted:检测命令管道中的重定向、shell 元字符unquotedKeepQuoteChars:检测"引号粘连 hash"攻击(如'x'#用注释隐藏后续命令)
2.3.4 安全检查清单
bashSecurity.ts:77-101 定义了 23 种安全检查的数字标识符(使用数字 ID 而不是字符串,是为了避免把检查名一并写入遥测日志)。这里逐条列全,避免读者再回 grep:
const BASH_SECURITY_CHECK_IDS = {
INCOMPLETE_COMMANDS: 1, // 不完整命令(含未闭合引号/管道等)
JQ_SYSTEM_FUNCTION: 2, // jq 表达式中 `system(...)`
JQ_FILE_ARGUMENTS: 3, // jq 的 -f/--from-file 等读取外部脚本的参数
OBFUSCATED_FLAGS: 4, // 混淆的命令行标志(反斜杠/Unicode 拼接)
SHELL_METACHARACTERS: 5, // Shell 元字符(;、&、$ 等出现在敏感位置)
DANGEROUS_VARIABLES: 6, // 危险环境变量赋值(如 LD_PRELOAD)
NEWLINES: 7, // 命令中的裸换行符
DANGEROUS_PATTERNS_COMMAND_SUBSTITUTION: 8, // $(…) / `…` 命令替换
DANGEROUS_PATTERNS_INPUT_REDIRECTION: 9, // < / <<< / <(…) 等输入重定向
DANGEROUS_PATTERNS_OUTPUT_REDIRECTION: 10, // > / >> 等输出重定向(带兜底白名单)
IFS_INJECTION: 11, // IFS 注入攻击
GIT_COMMIT_SUBSTITUTION: 12, // git commit -m 中带命令替换
PROC_ENVIRON_ACCESS: 13, // /proc/<pid>/environ 读取
MALFORMED_TOKEN_INJECTION: 14, // 畸形 token 注入(绕过 shell-quote 解析)
BACKSLASH_ESCAPED_WHITESPACE: 15, // \\<space> 被用来伪装连续 token
BRACE_EXPANSION: 16, // 花括号展开 {a,b}
CONTROL_CHARACTERS: 17, // ASCII 控制字符(\x00-\x1F 等)
UNICODE_WHITESPACE: 18, // Unicode 空白(U+00A0 等冒充空格)
MID_WORD_HASH: 19, // 词中 # 把后续 token 截成注释
ZSH_DANGEROUS_COMMANDS: 20, // zsh 模块/builtin(zmodload、zf_* 等)
BACKSLASH_ESCAPED_OPERATORS: 21, // 用反斜杠遮蔽 ;、& 等操作符
COMMENT_QUOTE_DESYNC: 22, // 注释/引号同步错位('x'# 用注释隐藏后续命令)
QUOTED_NEWLINE: 23, // 引号内塞入换行绕过单行匹配
} as const
每种检查都有对应的 validator 函数。检测到问题时,命令不会被直接拒绝,而是标记为"不安全",触发权限确认对话框。这体现了一个核心原则:安全系统应该 fail-closed(检测到不确定性时默认询问用户),而不是 fail-open。
2.4 每子命令权限判定中的只读验证(readOnlyValidation.ts)
只读验证并非主链路的独立层,而是在每个子命令的权限判定函数 bashToolCheckPermission()(bashPermissions.ts:1050-1178)内部的第 8 步调用。整个函数的 8 步骨架(每一步的行号区间已校对到源码);其后还有一条 passthrough 兜底分支,不计入这 8 步:
| 步骤 | 名称 | 行号区间 | 命中后行为 |
|---|---|---|---|
| 1 | 精确匹配 deny/ask 规则 | 1058-1070 | deny / ask 立即返回 |
| 2 | 前缀/通配符 deny/ask 规则 | 1072-1104 | deny / ask 立即返回 |
| 3 | 路径约束检查(checkPathConstraints) | 1106-1122 | 非 passthrough 立即返回 |
| 4 | 精确匹配 allow 规则 | 1124-1127 | allow 立即返回 |
| 5 | 前缀/通配符 allow 规则 | 1129-1139 | allow 立即返回 |
| 6 | sed 约束检查(checkSedConstraints) | 1141-1145 | 非 passthrough 立即返回 |
| 7 | 权限模式分支(checkPermissionMode) | 1147-1151 | 非 passthrough 立即返回(acceptEdits/plan 等模式专用) |
| 8 | 只读验证:BashTool.isReadOnly(input) → checkReadOnlyConstraints() | 1153-1163 | 命中即 allow(reason: 'Read-only command is allowed') |
兜底分支(在 8 步之外):passthrough(1165-1177)—— 前 8 步均未命中时触发权限确认对话框,附带 exact-match 建议。
deny 规则的优先级高于只读放行:如果用户设置了 Bash(git status) 的 deny 规则,即使 git status 是只读命令也会被拒绝。同时注意第 6/7 步在第 8 步之前——sed 危险写入和 plan 模式都能在只读放行之前先把命令拦下。
readOnlyValidation.ts 长达 1,990 行,其中大部分是命令配置。它为 100+ 个常用命令定义了"安全标志白名单"。但在判定一条命令是否只读之前,checkReadOnlyConstraints() 还有一串关键的安全前置条件(readOnlyValidation.ts:1882-1966):
// readOnlyValidation.ts:34-49 (简化展示)
type CommandConfig = {
safeFlags: Record<string, FlagArgType> // 安全标志及其参数类型
regex?: RegExp // 额外正则验证
additionalCommandIsDangerousCallback?: (
rawCommand: string, args: string[]
) => boolean // 自定义危险检测
respectsDoubleDash?: boolean // 是否支持 -- 分隔符
}
以 fd(文件搜索工具)的安全标志配置为例:
// readOnlyValidation.ts:55-100 (部分)
const FD_SAFE_FLAGS: Record<string, FlagArgType> = {
'-h': 'none', '--help': 'none',
'-H': 'none', '--hidden': 'none',
'-i': 'none', '--ignore-case': 'none',
'-d': 'number', '--max-depth': 'number',
'-t': 'string', '--type': 'string',
'-e': 'string', '--extension': 'string',
// SECURITY: -x/--exec 和 -X/--exec-batch 被刻意排除
// 它们会对每个搜索结果执行任意命令
// SECURITY: -l/--list-details 也被排除
// 它内部执行 ls 子进程,存在 PATH 劫持风险
}
注意注释中的安全考量: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 权限规则的三种形态
// bashPermissions.ts (通过 shellRuleMatching.ts)
type ShellPermissionRule =
| { type: 'exact'; command: string } // 精确匹配: "npm run build"
| { type: 'prefix'; prefix: string } // 前缀匹配: "npm run:*" → "npm run"
| { type: 'wildcard'; pattern: string } // 通配符: "git *"
权限规则有三种来源:alwaysAllowRules(自动批准)、alwaysDenyRules(自动拒绝)、alwaysAskRules(总是询问)。规则按 source 分层(参见第 19 章权限系统)。
3.2 环境变量剥离与包装器剥离
一个巧妙的设计是"安全包装器剥离"。当 AI 执行 NODE_ENV=test npm run build 时,权限系统需要把它识别为 npm run build 来匹配规则。
// bashPermissions.ts:378-399 (部分)
const SAFE_ENV_VARS = new Set([
'GOEXPERIMENT', 'GOOS', 'GOARCH', 'CGO_ENABLED', 'GO111MODULE',
'RUST_BACKTRACE', 'RUST_LOG',
'NODE_ENV',
'PYTHONUNBUFFERED', 'PYTHONDONTWRITEBYTECODE',
// ...
]);
// SECURITY: 以下变量绝不能加入白名单:
// PATH, LD_PRELOAD, LD_LIBRARY_PATH, DYLD_*(执行/库加载)
// PYTHONPATH, NODE_PATH(模块加载)
// GOFLAGS, RUSTFLAGS, NODE_OPTIONS(可含代码执行标志)
安全白名单严格区分了"只改变行为配置"的环境变量和"可以执行代码"的环境变量。PATH=evil npm run build 不会被剥离——因为 PATH 可以用来劫持二进制。
类似地,安全的包装器命令(nice、timeout、time 等)也会被剥离后再进行匹配。但 sudo、env、bash -c 等永远不会被建议为权限规则前缀,因为 Bash(sudo:*) 等价于 Bash(*):
// bashPermissions.ts:196-226
const BARE_SHELL_PREFIXES = new Set([
'sh', 'bash', 'zsh', 'fish', 'csh', 'ksh', 'dash',
'env', 'xargs',
'nice', 'stdbuf', 'nohup', 'timeout', 'time',
'sudo', 'doas', 'pkexec',
]);
3.3 智能规则建议
当用户批准一条命令时,系统会自动建议可复用的权限规则。getSimpleCommandPrefix() 提取命令前缀(如 git commit),suggestionForExactCommand() 决定建议的规则形式:
// bashPermissions.ts:161-188
export function getSimpleCommandPrefix(command: string): string | null {
const tokens = command.trim().split(/\s+/).filter(Boolean);
// 跳过安全环境变量赋值
let i = 0;
while (i < tokens.length && ENV_VAR_ASSIGN_RE.test(tokens[i]!)) {
const varName = tokens[i]!.split('=')[0]!;
if (!SAFE_ENV_VARS.has(varName)) return null; // 非安全环境变量 → 不建议前缀
i++;
}
const remaining = tokens.slice(i);
if (remaining.length < 2) return null;
const subcmd = remaining[1]!;
// 第二个 token 必须"看起来像子命令"(小写字母数字)
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(subcmd)) return null;
return remaining.slice(0, 2).join(' ');
}
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 复合命令的安全上限
为防止恶意构造的超长复合命令导致系统卡死,权限检查设置了硬上限:
// bashPermissions.ts:103
export const MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50;
// bashPermissions.ts:110
export const MAX_SUGGESTED_RULES_FOR_COMPOUND = 5;
超过 50 个子命令时直接要求用户确认(安全默认值),超过 5 条建议规则时合并为"similar commands"。这个限制源自一个真实的性能事件(CC-643):某些复合命令在 splitCommand 后产生指数级增长的子命令数组,每个子命令都要跑 tree-sitter 解析 + 20+ 个安全检查 + logEvent,最终导致事件循环饿死。
四、沙箱执行:命令运行时的隔离
通过了所有安全检查后,命令进入执行阶段。shouldUseSandbox.ts 决定命令是否在沙箱中运行。
4.1 沙箱决策逻辑
// shouldUseSandbox.ts:130-153
export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
if (!SandboxManager.isSandboxingEnabled()) return false;
// 显式禁用且策略允许
if (input.dangerouslyDisableSandbox
&& SandboxManager.areUnsandboxedCommandsAllowed()) return false;
if (!input.command) return false;
// 用户配置的排除命令
if (containsExcludedCommand(input.command)) return false;
return true;
}
沙箱的排除检查(containsExcludedCommand)同样经过精心设计。它不只检查整个命令,而是先拆分复合命令,对每个子命令独立检查。这防止了 docker ps && curl evil.com 因为 docker 在排除列表中而整个命令跳出沙箱的攻击。
4.2 排除命令的迭代剥离
// shouldUseSandbox.ts:82-101
const candidates = [trimmed];
const seen = new Set(candidates);
let startIdx = 0;
while (startIdx < candidates.length) {
const endIdx = candidates.length;
for (let i = startIdx; i < endIdx; i++) {
const cmd = candidates[i]!;
const envStripped = stripAllLeadingEnvVars(cmd, BINARY_HIJACK_VARS);
if (!seen.has(envStripped)) { candidates.push(envStripped); seen.add(envStripped); }
const wrapperStripped = stripSafeWrappers(cmd);
if (!seen.has(wrapperStripped)) { candidates.push(wrapperStripped); seen.add(wrapperStripped); }
}
startIdx = endIdx;
}
这个"不动点迭代"算法处理交错出现的环境变量和包装器,如 timeout 300 FOO=bar bazel run——单次剥离无法处理,但迭代直到没有新候选项产生即可。
4.3 沙箱的 Prompt 指令
prompt.ts:172-273 中的 getSimpleSandboxSection() 将沙箱配置(文件系统读写权限、网络白名单)序列化为 JSON 注入 System Prompt,让模型知道沙箱的限制:
// prompt.ts:188-203 (简化)
const filesystemConfig = {
read: { denyOnly: dedup(fsReadConfig.denyOnly) },
write: {
allowOnly: normalizeAllowOnly(fsWriteConfig.allowOnly),
denyWithinAllow: dedup(fsWriteConfig.denyWithinAllow),
},
};
一个细节:临时目录路径(如 /private/tmp/claude-1001/)被规范化为 $TMPDIR,避免因用户 UID 不同导致 prompt 差异,从而破坏跨用户的全局 Prompt Cache。
五、命令执行与输出处理
5.1 AsyncGenerator 驱动的执行
runShellCommand()(BashTool.tsx:826-1143)是一个 AsyncGenerator,通过 yield 产出进度更新,通过 return 返回最终结果:
// BashTool.tsx:826-853 (类型签名)
async function* runShellCommand(params): AsyncGenerator<
{ type: 'progress'; output: string; fullOutput: string;
elapsedTimeSeconds: number; totalLines: number;
totalBytes?: number; taskId?: string; timeoutMs?: number },
ExecResult, // return 类型
void
> {
// ...
}
调用端用 do...while 循环消费生成器:
// BashTool.tsx:660-682
let generatorResult;
do {
generatorResult = await commandGenerator.next();
if (!generatorResult.done && onProgress) {
const progress = generatorResult.value;
onProgress({
toolUseID: `bash-progress-${progressCounter++}`,
data: { type: 'bash_progress', ...progress }
});
}
} while (!generatorResult.done);
result = generatorResult.value; // 最终的 ExecResult
5.2 进度显示的 2 秒阈值
// BashTool.tsx:55
const PROGRESS_THRESHOLD_MS = 2000;
命令启动后的前 2 秒内不显示进度。如果命令在 2 秒内完成(大多数情况),用户看到的是即时结果,没有闪烁的进度条。只有超过 2 秒的命令才显示实时输出:
// BashTool.tsx:1006-1014
const initialResult = await Promise.race([
resultPromise,
new Promise<null>(resolve => {
const t = setTimeout(r => r(null), PROGRESS_THRESHOLD_MS, resolve);
t.unref(); // 不阻止进程退出
})
]);
if (initialResult !== null) {
shellCommand.cleanup();
return initialResult; // 2 秒内完成,直接返回
}
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)
// BashTool.tsx:57
const ASSISTANT_BLOCKING_BUDGET_MS = 15_000;
// BashTool.tsx:976-983
if (feature('KAIROS') && getKairosActive() && isMainThread
&& !isBackgroundTasksDisabled && run_in_background !== true) {
setTimeout(() => {
if (shellCommand.status === 'running' && backgroundShellId === undefined) {
assistantAutoBackgrounded = true;
startBackgrounding('tengu_bash_command_assistant_auto_backgrounded');
}
}, ASSISTANT_BLOCKING_BUDGET_MS).unref();
}
5.4 大输出的持久化处理
BashTool 的输出持久化有两个独立的机制:
机制一:Shell 层面的文件模式输出。 当 Shell 命令的 stdout 超过一定大小时,TaskOutput 会将输出写入磁盘文件而非全部保存在内存中。命令执行完成后,BashTool 检测到 result.outputFilePath 存在时,将输出文件硬链接(失败则复制)到 tool-results/ 目录,并生成 <persisted-output> 格式的预览供模型读取:
// BashTool.tsx:732
const MAX_PERSISTED_SIZE = 64 * 1024 * 1024; // 64MB 上限,超过则截断
机制二:通用 tool_result 持久化阈值。 maxResultSizeChars: 30_000 是 Tool 接口的通用机制(定义在 Tool.ts),当工具返回的结果文本超过此阈值时,由框架层将结果持久化到磁盘。BashTool 将此阈值设为 30K(其他工具默认 100K),因为 Shell 输出通常较长。
这两个机制是互补的:Shell 层面的文件模式处理的是执行期间的大量输出流,而 maxResultSizeChars 处理的是结果返回时的大小控制。
5.5 图片输出检测
// BashTool.tsx:785-802
let isImage = isImageOutput(strippedStdout);
if (isImage) {
const resized = await resizeShellImageOutput(
strippedStdout, result.outputFilePath, persistedOutputSize
);
if (resized) {
compressedStdout = resized;
} else {
isImage = false; // 解析失败或文件过大,回退为文本
}
}
当命令输出是 base64 编码的图片时(如截图工具),BashTool 检测到后将其作为 image content block 发送给模型,让 Claude 能"看到"图片内容。过大的图片会被压缩或回退为文本。
六、Prompt 工程:引导 AI 正确使用 BashTool
prompt.ts 生成的 BashTool 描述不仅告诉模型这个工具能做什么,还包含了大量行为引导指令。
6.1 工具偏好引导
// prompt.ts:280-291
const toolPreferenceItems = [
`File search: Use ${GLOB_TOOL_NAME} (NOT find or ls)`,
`Content search: Use ${GREP_TOOL_NAME} (NOT grep or rg)`,
`Read files: Use ${FILE_READ_TOOL_NAME} (NOT cat/head/tail)`,
`Edit files: Use ${FILE_EDIT_TOOL_NAME} (NOT sed/awk)`,
`Write files: Use ${FILE_WRITE_TOOL_NAME} (NOT echo >/cat <<EOF)`,
'Communication: Output text directly (NOT echo/printf)',
];
这些指令引导模型优先使用专用工具(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 的安全设计
// BashTool.tsx:227-259
const fullInputSchema = lazySchema(() => z.strictObject({
command: z.string(),
timeout: semanticNumber(z.number().optional()),
description: z.string().optional(),
run_in_background: semanticBoolean(z.boolean().optional()),
dangerouslyDisableSandbox: semanticBoolean(z.boolean().optional()),
_simulatedSedEdit: z.object({
filePath: z.string(),
newContent: z.string()
}).optional()
}));
// 从模型可见的 schema 中移除 _simulatedSedEdit
const inputSchema = lazySchema(() =>
fullInputSchema().omit({ _simulatedSedEdit: true })
);
_simulatedSedEdit 是一个内部字段,用于 sed 编辑预览后的精确写入。它被从模型可见的 schema 中移除,因为暴露它会让模型绕过权限检查和沙箱——模型可以发送一个无害的 command 配合任意 _simulatedSedEdit 来写入任何文件。
七、特殊处理:sed 编辑的文件编辑化
BashTool 对 sed -i 命令有一套完整的特殊处理流程。sedEditParser.ts(322 行)将 sed 命令解析为结构化的编辑操作:
// sedEditParser.ts:23-33
export type SedEditInfo = {
filePath: string // 被编辑的文件路径
pattern: string // 搜索模式(正则)
replacement: string // 替换字符串
flags: string // 替换标志(g, i 等)
extendedRegex: boolean // 是否使用扩展正则(-E/-r)
}
当检测到 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):
// destructiveCommandWarning.ts:12-89 (部分)
const DESTRUCTIVE_PATTERNS: DestructivePattern[] = [
{ pattern: /\bgit\s+reset\s+--hard\b/,
warning: 'Note: may discard uncommitted changes' },
{ pattern: /\bgit\s+push\b[^;&|\n]*--force\b/,
warning: 'Note: may overwrite remote history' },
{ pattern: /\bgit\s+clean\b(?![^;&|\n]*--dry-run)[^;&|\n]*-[a-zA-Z]*f/,
warning: 'Note: may permanently delete untracked files' },
{ pattern: /\bkubectl\s+delete\b/,
warning: 'Note: may delete Kubernetes resources' },
{ pattern: /\bterraform\s+destroy\b/,
warning: 'Note: may destroy Terraform infrastructure' },
];
注意 git clean 的正则排除了 --dry-run 标志——如果命令包含 dry run,就不需要警告。这种对 CLI 工具语义的精确理解贯穿了整个 BashTool 的设计。
九、可迁移的设计模式
模式 1:纵深防御架构
将安全检查分为多个独立的维度(AST 结构验证 → 语义检查 → 权限规则匹配 → 路径约束 → 只读白名单 → 沙箱),在权限主链路中按优先级编排。任何一个维度都可以独立地阻止危险操作,同时 AST 不可用时可以回退到正则路径。
适用场景:任何允许用户/AI 执行动态操作的系统。不要依赖单一的安全检查点。
模式 2:Fail-Closed 白名单模式
使用明确的白名单(而非黑名单)来定义安全行为。tree-sitter AST 分析中,只有白名单中的节点类型被允许通过,所有未知结构默认拒绝。只读命令验证中,只有白名单标志被认为安全。
适用场景:安全敏感的解析和验证场景。黑名单永远不完整(有无限种攻击方式),白名单则有有限的已知安全项。
模式 3:语义分类驱动的差异化处理
对输入进行语义分类(搜索/读取/列表/静默/破坏性),根据分类结果采取不同的 UI 展示和安全策略。这比"一刀切"的处理方式能同时提供更好的用户体验和更精确的安全控制。
适用场景:处理多种类型输入的工具系统。电商系统中的订单(预付/货到付款/退款)、日志系统中的事件(info/warn/error)等都可以从语义分类中获益。
十、Windows 路径上的对照:PowerShellTool
整本书的视角一直站在 macOS / Linux 这一侧,把 BashTool 当成"AI 在用户机器上执行任意代码"的唯一通道。但翻开 tools/PowerShellTool/ 目录会发现,Windows 用户其实走的是另一条几乎平行的路径——14 个源码文件、合计 8,959 行代码,与 BashTool 那 18 个文件、12,411 行的庞大体量虽不在一个量级,但整体形状几乎是镜像的。
镜像主要体现在三件事上。
入口与生命周期完全对齐。tools/PowerShellTool/PowerShellTool.tsx(1,000 行)和 BashTool.tsx(1,143 行)几乎是同一份骨架:同样的 PROGRESS_THRESHOLD_MS = 2000、ASSISTANT_BLOCKING_BUDGET_MS = 15_000(PowerShellTool.tsx:159–162),同样的 runPowerShellCommand AsyncGenerator(PowerShellTool.tsx:663),同样的 detectBlockedSleepPattern(PowerShellTool.tsx:189)只是把检测目标从 sleep 换成了 Start-Sleep。isSearchOrReadPowerShellCommand()(PowerShellTool.tsx:101)干的事和 BashTool 那套 BASH_SEARCH/READ/LIST 集合一模一样,只不过命令名换成了 select-string、get-content、write-output 这些 PowerShell cmdlet(PowerShellTool.tsx:54–93)。
安全防线的层数和顺序也对齐。powershellPermissions.ts(1,648 行)对应 bashPermissions.ts(2,621 行);powershellSecurity.ts(1,090 行)对应 bashSecurity.ts(2,592 行);readOnlyValidation.ts 在两边都各有一份(1,823 行 vs 1,990 行)。pathValidation.ts 甚至更厚——PowerShell 这边有 2,049 行,比 Bash 的 1,303 行还多,因为 Windows 路径要处理的边角情形(drive-relative C:foo、PS provider 前缀 FileSystem::、NTFS 尾部空格 / 点剥离、/ 与 \ 互通、–/—/― 各种 dash 字符当 parameter 前缀)远比 POSIX 路径多。
但又有两块东西是 PowerShell 独有的,BashTool 那边没有对应物。
第一块是 gitSafety.ts(176 行)。它要解决的攻击场景和 BashTool 的 cd + git 防护完全一样——bare repo hook 攻击、git 内部目录写入后再跑 git——但 Windows 路径的特殊性逼出了一整套独立的归一化逻辑:normalizeGitPathArg()(gitSafety.ts:48 起)要把 PS 的 colon-bound parameter(/Path:hooks/pre-commit)、provider 前缀(Microsoft.PowerShell.Core\FileSystem::path)、drive-relative 路径(C:foo 是 cwd-relative,C:\foo 是绝对路径,二者必须区别对待)、NTFS 尾部空格 / 点剥离全部归约成一条规范路径,然后才能去比对 hooks/、HEAD、objects/ 这些 git 内部目录前缀。BashTool 没有这一层,是因为 POSIX 文件系统不会把 hooks 和 hooks 当作同一个文件,而 NTFS 会。
第二块是 clmTypes.ts(211 行),对应的是 PowerShell 那条 BashTool 里完全不存在的攻击面:.NET 类型实例化。一行看起来很无辜的 PowerShell 代码 [adsi]'LDAP://evil.com/...',只是一次"类型转换",但运行时会真的去和远端 LDAP 服务器建立网络连接——这在 bash 里没有等价物。Microsoft 的 Constrained Language Mode 维护了一份"在系统锁定状态下仍然可以使用"的 .NET 类型白名单(clmTypes.ts 的 CLM_ALLOWED_TYPES),PowerShellTool 把这份白名单当成自己的判定基准:AST 解析时遇到的任意 TypeName.Name,只要不在这份白名单里就拒绝放行,要求用户确认。值得注意的一处倒退:adsi 和 adsisearcher 被显式从 Microsoft 的原白名单中摘掉了——Microsoft 允许它们是因为预设场景是 Windows 域内管理员,而 Claude Code 的场景里调用方未必可信。
还有几处"形似而不神似"的差异值得点一句:
- 沙箱:BashTool 的
shouldUseSandbox.ts(153 行)在 macOS / Linux 上通过 sandbox-exec 或 namespace 隔离实现;而 PowerShellTool.tsx:210 写得很直白——"Enterprise policy requires sandboxing, but sandboxing is not available on native Windows",原生 Windows 上根本没有等价的沙箱原语,于是策略要求沙箱时只能拒绝执行而不是 silently 跳过。 - 版本分裂:PowerShell 5.1 和 PowerShell 7+ 的语法不兼容(5.1 不支持
&&/||,会直接 parser error),prompt.ts因此要在 System Prompt 里把当前检测到的PowerShellEdition告诉模型,避免它写出"在训练数据里看着合法、运行时直接 exit 1"的命令。BashTool 没有这种问题,因为 bash 的语法在主流版本间稳定得多。 - 管道语义:PowerShell 的管道传递的是 .NET 对象而不是字节流,
commandSemantics.ts(142 行,对照 BashTool 那边的 140 行)的退出码语义表大小一致,但 PowerShell 这边要额外处理$?/$LASTEXITCODE两套退出状态。
回头看 BashTool 那 12,411 行和 PowerShellTool 那 8,959 行,真正想强调的事其实只有一件:让 AI 在用户机器上跑代码这件事,没法做成一份"通用 Shell 工具"。Shell 的差异(POSIX vs PowerShell)、文件系统的差异(大小写敏感、路径规范化、文件名合法字符)、隔离原语的差异(sandbox-exec vs "没有")、攻击面的差异(命令注入 vs .NET 类型实例化)逼着 Claude Code 不得不在 tools/ 下养着两套几乎平行的实现。看似冗余,实则是"在两套操作系统语义下都要 fail-closed"的最小代价。
下一章预告
第 12 章:文件、代码与 LSP 协作族 — 从 Read 到 LSP 的工程一致性
我们将看 FileRead / FileWrite / FileEdit / NotebookEdit / Glob / Grep / LSPTool / REPLTool 八个工具,外加 services/lsp/ 这一摞被 LSPTool 架起来的服务,合起来回答「一个 Agent 想读懂并改动一份代码仓库」这件事。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)