4.3 BashTool 防御链 --- 七层安全防御的工程实现
对应原书 :第4章 4.7节"BashTool 的七层安全防御"、4.7.1节"命令清洗的固定点迭代"、4.7.2节"Sed 模拟执行"、4.7.3节"AsyncGenerator 驱动的流式执行"、4.10节"与其他框架对比"、4.11节"工具沙箱隔离概述"
辅助源码 :
tools/BashTool/目录全部文件(BashTool.tsx、bashSecurity.ts、bashPermissions.ts、pathValidation.ts、sedValidation.ts、readOnlyValidation.ts、shouldUseSandbox.ts、commandSemantics.ts、destructiveCommandWarning.ts、modeValidation.ts)、utils/bash/ast.ts重点关注:七层防御架构、AST 解析(Tree-sitter)、权限决策引擎、路径约束系统、Sed 约束白名单、命令清洗固定点迭代、Sed 模拟执行、AsyncGenerator 流式执行
1. 导语:为什么 BashTool 是"重中之重"
原书第4章将 BashTool 的安全防御称为整个工具系统的"重中之重"。这并非夸张------BashTool 是唯一一个允许 LLM 执行任意 shell 命令的工具,一旦被 Prompt 注入绕过,攻击者就能获得完整的 shell 访问权限。与 FileEditTool(只能写文件)或 GlobTool(只能搜索文件)不同,BashTool 的攻击面是整个操作系统。
源码验证了这一判断:tools/BashTool/ 目录包含 10+ 个专用安全文件 ,总代码量超过 6000 行,远超任何其他工具。这些文件构成了一个从命令解析到执行监控的完整防御链。
tools/BashTool/
├── BashTool.tsx # 主组件(53KB)--- 工具定义 + AsyncGenerator 执行
├── bashSecurity.ts # 危险模式检测(20K+ tokens)--- 23 种安全检查
├── bashPermissions.ts # 权限决策引擎(83.5KB)--- deny/ask/allow 规则匹配
├── pathValidation.ts # 路径约束(1304行)--- 35 种 PathCommand 路径验证
├── sedValidation.ts # Sed 约束(685行)--- 双模式白名单
├── readOnlyValidation.ts # 只读验证(76.3KB)--- 多组只读命令白名单
├── shouldUseSandbox.ts # 沙箱决策(154行)--- 排除命令检测
├── commandSemantics.ts # 命令语义(141行)--- exit code 解释器
├── destructiveCommandWarning.ts # 破坏性警告(103行)--- 15 种危险模式
├── modeValidation.ts # 模式验证(116行)--- acceptEdits 自动放行
├── bashCommandHelpers.ts # 命令辅助 --- 操作符权限检查
├── sedEditParser.ts # Sed 解析 --- 表达式到 JS 替换转换
└── utils.ts / toolName.ts / ... # 辅助工具
2. 七层防御架构总览
原书 4.7 节描述了七层防御的流水线结构。源码验证了这一架构,但实际实现远比原书描述的更精细:
命令字符串
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Layer 1: AST 解析 (Tree-sitter) │
│ ast.ts: parseForSecurityFromAst() → SimpleCommand[] │
│ 预检查:控制字符 / Unicode空白 / 反斜杠空白 / Zsh扩展 / 花括号引号 │
│ 语义检查:checkSemantics() --- eval/exec/source/. 等危险命令名 │
├─────────────────────────────────────────────────────────────────┤
│ Layer 2: 精确匹配规则 (Exact Match) │
│ bashPermissions.ts: bashToolCheckExactMatchPermission() │
│ 用户配置的 deny / ask / allow 精确匹配 │
├─────────────────────────────────────────────────────────────────┤
│ Layer 3: 前缀/通配符规则 (Prefix/Wildcard) │
│ bashPermissions.ts: bashPermissionRule() + matchWildcardPattern()│
│ deny 规则用 stripAllLeadingEnvVars(激进剥离) │
│ allow 规则用 stripSafeWrappers(保守剥离) │
├─────────────────────────────────────────────────────────────────┤
│ Layer 4: 路径约束 (Path Constraints) │
│ pathValidation.ts: checkPathConstraints() │
│ 35 种 PathCommand 路径提取 + -- 端标志 + 危险路径检测 │
│ 重定向目标验证 + cd+write 组合检测 │
├─────────────────────────────────────────────────────────────────┤
│ Layer 5: Sed 约束 (Sed Constraints) │
│ sedValidation.ts: checkSedConstraints() │
│ Pattern 1: 行打印白名单 (-n + p/Np/N,Mp) │
│ Pattern 2: 替换命令白名单 (s/pattern/replacement/flags) │
│ denylist: w/W/e/E 命令 / 非ASCII / 花括号 / 换行 / 注释 │
├─────────────────────────────────────────────────────────────────┤
│ Layer 6: 只读自动放行 (Read-Only Auto-Allow) │
│ readOnlyValidation.ts: validateReadOnlyCommand() │
│ GIT/GH/RIPGREP/PYRIGHT/DOCKER/EXTERNAL 多组白名单 │
│ validateFlags() flag 白名单 + UNC 路径漏洞检测 │
├─────────────────────────────────────────────────────────────────┤
│ Layer 7: 分类器审批 (Classifier Approval) │
│ bashPermissions.ts: classifyBashCommand() + bashClassifier │
│ LLM 分类器 + 用户审批 UI + pendingClassifierCheck │
└─────────────────────────────────────────────────────────────────┘
│
▼
执行 / 拒绝 / 询问用户
2.1 防御层与源码文件映射表
| 防御层 | 原书描述 | 核心源码文件 | 关键函数 |
|---|---|---|---|
| Layer 1 | 命令解析与 AST 分析 | utils/bash/ast.ts |
parseForSecurityFromAst() / checkSemantics() |
| Layer 2 | 精确匹配权限规则 | bashPermissions.ts |
bashToolCheckExactMatchPermission() |
| Layer 3 | 前缀/通配符权限规则 | bashPermissions.ts |
bashPermissionRule() / matchWildcardPattern() |
| Layer 4 | 路径检查 | pathValidation.ts |
checkPathConstraints() / validateSinglePathCommandArgv() |
| Layer 5 | Sed 命令约束 | sedValidation.ts |
checkSedConstraints() / sedCommandIsAllowedByAllowlist() |
| Layer 6 | 只读命令自动放行 | readOnlyValidation.ts |
validateReadOnlyCommand() / validateFlags() |
| Layer 7 | 分类器与用户审批 | bashPermissions.ts + bashClassifier.ts |
classifyBashCommand() / isClassifierPermissionsEnabled() |
3. Layer 1: AST 解析 --- Tree-sitter 的精确命令分析
3.1 从 shell-quote 到 Tree-sitter 的迁移
原书提到 Claude Code 使用 AST 解析来理解命令结构。源码揭示了一个重要的架构演进:从旧的 shell-quote 解析器迁移到 Tree-sitter bash 语法分析器。
bashPermissions.ts 的 bashToolHasPermission() 函数(第 1663 行)是整个权限决策的入口:
typescript
export async function bashToolHasPermission(
input: z.infer<typeof BashTool.inputSchema>,
context: ToolUseContext,
getCommandSubcommandPrefixFn = getCommandSubcommandPrefix,
): Promise<PermissionResult> {
// 0. AST-based security parse
const injectionCheckDisabled = isEnvTruthy(
process.env.CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK,
)
const shadowEnabled = feature('TREE_SITTER_BASH_SHADOW')
? getFeatureValue_CACHED_MAY_BE_STALE('tengu_birch_trellis', true)
: false
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' }
这里有三层 Feature Flag 控制:
CLAUDE_CODE_DISABLE_COMMAND_INJECTION_CHECK:环境变量紧急开关TREE_SITTER_BASH_SHADOW:编译期 Feature Flag,控制 Tree-sitter 代码是否被包含tengu_birch_trellis:GrowthBook 运行时开关,支持灰度发布
3.2 parseForSecurityFromAst:六道预检查
ast.ts 第 400 行的 parseForSecurityFromAst() 在 Tree-sitter 解析前执行六道预检查,拦截 Tree-sitter 与 bash 在词法边界上的已知差异:
typescript
export function parseForSecurityFromAst(
cmd: string,
root: Node | typeof PARSE_ABORTED,
): ParseForSecurityResult {
// 预检查 1: 控制字符
if (CONTROL_CHAR_RE.test(cmd)) {
return { kind: 'too-complex', reason: 'Contains control characters' }
}
// 预检查 2: Unicode 空白字符(如 \u00A0,可隐藏内容)
if (UNICODE_WHITESPACE_RE.test(cmd)) {
return { kind: 'too-complex', reason: 'Contains Unicode whitespace' }
}
// 预检查 3: 反斜杠转义的空白字符
if (BACKSLASH_WHITESPACE_RE.test(cmd)) {
return { kind: 'too-complex', reason: 'Contains backslash-escaped whitespace' }
}
// 预检查 4: Zsh ~[ 动态目录语法
if (ZSH_TILDE_BRACKET_RE.test(cmd)) {
return { kind: 'too-complex', reason: 'Contains zsh ~[ dynamic directory syntax' }
}
// 预检查 5: Zsh =cmd 等号扩展
if (ZSH_EQUALS_EXPANSION_RE.test(cmd)) {
return { kind: 'too-complex', reason: 'Contains zsh =cmd equals expansion' }
}
// 预检查 6: 花括号+引号混淆
if (BRACE_WITH_QUOTE_RE.test(maskBracesInQuotedContexts(cmd))) {
return { kind: 'too-complex', reason: 'Contains brace with quote character' }
}
// ... 然后 walkProgram(root) 遍历 AST
这些预检查的设计哲学是Fail-Closed :只要发现 Tree-sitter 可能与 bash 解析不一致的输入,直接返回 too-complex,降级为询问用户。
3.3 PARSE_ABORTED 对抗性输入处理
一个关键的安全细节------当 Tree-sitter 解析超时或资源耗尽时(可被对抗性输入触发),不降级为旧的 shell-quote 路径,而是直接 fail-closed:
typescript
if (root === PARSE_ABORTED) {
// SECURITY: module loaded but parse aborted (timeout / node budget /
// panic). Adversarially triggerable --- `(( a[0][0]... ))` with ~2800
// subscripts hits PARSE_TIMEOUT_MICROS under the 10K length limit.
// Previously indistinguishable from module-not-loaded → routed to
// legacy (parse-unavailable), which lacks EVAL_LIKE_BUILTINS --- `trap`,
// `enable`, `hash` leaked with Bash(*). Fail closed: too-complex → ask.
return {
kind: 'too-complex',
reason: 'Parser aborted (timeout or resource limit) --- possible adversarial input',
nodeType: 'PARSE_ABORT',
}
}
注释明确指出了一个真实漏洞:(( a[0][0]... )) 嵌套约 2800 层下标就能触发解析超时,旧代码会降级到 legacy 路径,而 legacy 路径缺少 EVAL_LIKE_BUILTINS 检查,导致 trap、enable、hash 等危险命令泄漏。
3.4 checkSemantics:命令名级别的语义检查
ast.ts 第 2213 行的 checkSemantics() 在 AST 解析成功后执行命令名级别的语义检查:
typescript
export function checkSemantics(commands: SimpleCommand[]): SemanticCheckResult {
for (const cmd of commands) {
// 剥离安全包装器(nohup, time, timeout N, nice -n N)
let a = cmd.argv
for (;;) {
if (a[0] === 'time' || a[0] === 'nohup') {
a = a.slice(1)
} else if (a[0] === 'timeout') {
// 解析 GNU timeout 的所有 flag 形式...
let i = 1
while (i < a.length) {
// --foreground, --preserve-status, --verbose (无值)
// --kill-after=N, --signal=SIG (等号融合值)
// --kill-after N, --signal SIG (空格分隔值)
// -v, -k DUR, -s SIG, -k5, -sTERM (短 flag)
// 未知 flag → fail closed
}
// ... 验证 duration 格式
} else if (a[0] === 'nice') {
// nice cmd / nice -n N cmd / nice -N cmd
// nice $((0-5)) → fail closed(算术扩展无法静态分析)
} else {
break
}
}
// 检查命令名是否在危险列表中
// eval, exec, source, ., trap, enable, hash, system() 等
}
}
语义检查的核心是剥离安全包装器后检查实际命令名 。这意味着 nohup eval "rm -rf /" 不会被 Bash(nohup:*) 的 allow 规则放行------剥离 nohup 后暴露出 eval,语义检查直接拒绝。
4. Layer 2-3: 权限规则匹配引擎
4.1 三种规则类型
bashPermissions.ts 支持三种权限规则类型:
| 规则类型 | 语法示例 | 匹配方式 | 源码函数 |
|---------|---------|---------|---------|
| 精确匹配 | `Bash(npm test)` | 命令字符串完全一致 | bashToolCheckExactMatchPermission() |
| 前缀匹配 | `Bash(npm:*)` | 命令以 "npm " 开头 | bashPermissionRule() prefix 分支 |
| 通配符 | `Bash(git commit *)` | glob 模式匹配 | matchWildcardPattern() |
4.2 deny vs allow 的不对称剥离策略
这是整个权限引擎最精妙的设计------deny 规则和 allow 规则使用不同的环境变量剥离策略:
deny 规则用激进剥离 (stripAllLeadingEnvVars):
typescript
/**
* Strip ALL leading env var prefixes, regardless of whether the var
* name is in the safe-list.
*
* Used for deny/ask rule matching: when a user denies `claude` or `rm`,
* the command should stay blocked even if prefixed with arbitrary env vars
* like `FOO=bar claude`. The safe-list restriction in stripSafeWrappers
* is correct for allow rules (prevents `DOCKER_HOST=evil docker ps` from
* auto-matching `Bash(docker ps:*)`), but deny rules must be harder to
* circumvent.
*/
export function stripAllLeadingEnvVars(
command: string,
blocklist?: RegExp, // BINARY_HIJACK_VARS for excludedCommands
): string {
allow 规则用保守剥离 (stripSafeWrappers):
typescript
export function stripSafeWrappers(command: string): string {
// 只剥离已知安全的环境变量(SAFE_ENV_VARS 白名单)
// 只剥离已知安全的包装器(timeout/nice/nohup/time/stdbuf)
// ...
}
这种不对称设计的安全含义:
- deny 不可绕过 :
FOO=bar rm -rf /中的FOO=bar会被激进剥离,暴露出rm -rf /,匹配 deny 规则 - allow 不可扩大 :
DOCKER_HOST=evil docker ps中的DOCKER_HOST不在安全列表中,不会被剥离,因此不匹配Bash(docker ps:*)allow 规则,仍然需要询问用户
4.3 固定点迭代:命令清洗的核心算法
原书 4.7.1 节描述了"命令清洗的固定点迭代"。源码验证了这一设计------stripSafeWrappers 使用两阶段固定点迭代:
typescript
export function stripSafeWrappers(command: string): string {
let stripped = command
let previousStripped = ''
// Phase 1: 剥离环境变量和注释(固定点迭代)
while (stripped !== previousStripped) {
previousStripped = stripped
stripped = stripCommentLines(stripped)
const envVarMatch = stripped.match(ENV_VAR_PATTERN)
if (envVarMatch) {
const varName = envVarMatch[1]!
if (SAFE_ENV_VARS.has(varName) || isAntOnlySafe) {
stripped = stripped.replace(ENV_VAR_PATTERN, '')
}
}
}
// Phase 2: 剥离包装器和注释(固定点迭代,不剥离环境变量)
// SECURITY: 包装器后的 VAR=val 是命令而非赋值(HackerOne #3543050)
previousStripped = ''
while (stripped !== previousStripped) {
previousStripped = stripped
stripped = stripCommentLines(stripped)
for (const pattern of SAFE_WRAPPER_PATTERNS) {
stripped = stripped.replace(pattern, '')
}
}
return stripped.trim()
}
为什么需要两阶段? 注释中解释了一个 HackerOne 漏洞(#3543050):在 bash 中,VAR=val 出现在包装器之后会被当作命令执行,而非环境变量赋值。例如 nohup FOO=bar cmd 中,FOO=bar 是 nohup 的参数,bash 会尝试执行名为 FOO=bar 的命令。因此 Phase 2 不能剥离环境变量。
为什么需要固定点迭代? 因为环境变量和包装器可以交替出现:NODE_ENV=prod timeout 5 nice -n 10 npm test。每次迭代剥离一层,直到不再变化(收敛到固定点)。
4.4 stripWrappersFromArgv:AST 级包装器剥离
除了字符串级的 stripSafeWrappers,还有 AST 级的 stripWrappersFromArgv,用于从 Tree-sitter 解析出的 argv 数组中剥离包装器:
typescript
export function stripWrappersFromArgv(argv: string[]): string[] {
let a = argv
for (;;) {
if (a[0] === 'time' || a[0] === 'nohup') {
a = a.slice(a[1] === '--' ? 2 : 1)
} else if (a[0] === 'timeout') {
const i = skipTimeoutFlags(a) // 独立 flag 解析器
if (i < 0 || !a[i] || !/^\d+(?:\.\d+)?[smhd]?$/.test(a[i]!)) return a
a = a.slice(i + 1)
} else if (a[0] === 'nice' && a[1] === '-n' && a[2] && /^-?\d+$/.test(a[2])) {
a = a.slice(a[3] === '--' ? 4 : 3)
} else {
return a
}
}
}
skipTimeoutFlags 是一个独立的 flag 解析器,枚举 GNU timeout 的所有 flag 形式(长/短、融合/分隔),并使用 TIMEOUT_FLAG_VALUE_RE = /^[A-Za-z0-9_.+-]+$/ 白名单验证 flag 值,防止 timeout -k$(id) 10 ls 类注入。
4.5 BARE_SHELL_PREFIXES:阻止危险前缀建议
bashPermissions.ts 第 196 行定义了一组"裸 shell 前缀",阻止用户为这些命令创建前缀 allow 规则:
typescript
const BARE_SHELL_PREFIXES = new Set([
'sh', 'bash', 'zsh', 'fish', 'csh', 'tcsh', 'ksh', 'dash', // shell 解释器
'cmd', 'powershell', 'pwsh', // Windows shell
'env', 'xargs', // 命令包装器
'nice', 'stdbuf', 'nohup', 'timeout', 'time', // 安全包装器
'sudo', 'doas', 'pkexec', // 特权提升
])
如果允许 Bash(nice:*),那么 nice rm -rf / 会通过权限检查(剥离 nice 后匹配 rm 的 allow 规则)。因此这些命令被阻止出现在前缀建议中。
5. Layer 4: 路径约束系统 --- pathValidation.ts
5.1 35 种 PathCommand 的路径提取器
pathValidation.ts 定义了 35 种路径相关命令的路径提取器,每种命令都有定制的路径提取逻辑:
typescript
type PathCommand =
| 'cd' | 'ls' | 'find' | 'mkdir' | 'touch' | 'rm' | 'rmdir'
| 'mv' | 'cp' | 'cat' | 'head' | 'tail' | 'sort' | 'uniq'
| 'wc' | 'cut' | 'paste' | 'column' | 'tr' | 'file' | 'stat'
| 'diff' | 'awk' | 'strings' | 'hexdump' | 'od' | 'base64'
| 'nl' | 'grep' | 'rg' | 'sed' | 'git' | 'jq'
| 'sha256sum' | 'sha1sum' | 'md5sum'
PATH_EXTRACTORS 为每种命令定义了路径提取函数,处理各自的 flag 语义。例如 find 命令的第一个非 flag 参数是搜索路径,而 grep 的最后一个非 flag 参数是目标文件。
5.2 filterOutFlags:POSIX -- 端标志防注入
filterOutFlags() 实现了 POSIX -- 端标志处理,防止 rm -- -/../file 类绕过:
typescript
// `--` 之后的参数都是文件路径,不是 flag
// `rm -- -f` 中的 `-f` 是文件名而非 flag
function filterOutFlags(argv: string[]): string[] {
const result: string[] = []
let seenDoubleDash = false
for (const arg of argv) {
if (seenDoubleDash) {
result.push(arg) // -- 之后全部视为路径
} else if (arg === '--') {
seenDoubleDash = true
} else if (!arg.startsWith('-')) {
result.push(arg) // 非 flag 参数
}
// flag 参数被跳过
}
return result
}
5.3 checkDangerousRemovalPaths:危险路径检测
rm 和 rmdir 命令会检查目标路径是否指向关键系统目录:
typescript
function checkDangerousRemovalPaths(paths: string[]): PermissionResult | null {
const DANGEROUS_PATHS = [
'/', '/bin', '/boot', '/dev', '/etc', '/lib',
'/proc', '/root', '/sbin', '/sys', '/usr', '/var',
'/System', '/Library', '/Applications', // macOS
'C:\\Windows', 'C:\\Program Files', // Windows
]
// 检查路径规范化后是否匹配危险目录
}
5.4 compoundCommandHasCd:cd+write 组合检测
compoundCommandHasCd 检测 cd 与写命令的组合,防止路径解析绕过:
typescript
// `cd /etc && rm hosts` --- rm 的目标在 /etc 下,但单独的 `rm hosts`
// 在权限规则中可能匹配 `Bash(rm:*)` allow 规则
// compoundCommandHasCd 检测到 cd+rm 组合后,拒绝自动放行
function compoundCommandHasCd(commands: SimpleCommand[]): boolean {
// ...
}
5.5 validateOutputRedirections:重定向目标验证
重定向目标也被纳入路径约束:
typescript
// `echo evil > /etc/passwd` --- 重定向目标是写操作
// validateOutputRedirections 检查 > >> < 的目标路径
function validateOutputRedirections(redirects: Redirect[]): PermissionResult | null {
// ...
}
5.6 AST 路径 vs shell-quote 路径
源码中有两套路径验证函数,反映了从 shell-quote 到 AST 的迁移:
| 函数 | 数据来源 | 状态 | 说明 |
|------|---------|------|------|
| validateSinglePathCommandArgv() | AST-derived argv | 优先使用 | Tree-sitter 解析的 argv |
| validateSinglePathCommand() | shell-quote re-parse | 降级使用 | shell-quote 有已知单引号反斜杠 bug |
stripWrappersFromArgv 也在注释中强调需要与 stripSafeWrappers 保持同步:
typescript
/**
* Argv-level counterpart to stripSafeWrappers. Strips the same wrapper
* commands (timeout, time, nice, nohup) from AST-derived argv. Env vars
* are already separated into SimpleCommand.envVars so no env-var stripping.
*
* KEEP IN SYNC with SAFE_WRAPPER_PATTERNS above --- if you add a wrapper
* there, add it here too.
*/
6. Layer 5: Sed 约束 --- sedValidation.ts
6.1 双模式白名单架构
sedValidation.ts(685 行)实现了两种安全 sed 模式:
Pattern 1: 行打印命令
typescript
function isLinePrintingCommand(expr: string): boolean {
// -n 抑制自动输出 + p/Np/N,Mp 打印特定行
// 严格白名单:只允许 -n flag + p 命令
// 示例:sed -n '5p' file, sed -n '10,20p' file
}
Pattern 2: 替换命令
typescript
function isSubstitutionCommand(expr: string): boolean {
// s/pattern/replacement/flags
// flags 仅允许:g(全局) p(打印) i/I(忽略大小写) m/M(多行)
// 不允许 e(执行命令) --- 这是最大的安全风险
}
6.2 containsDangerousOperations:denylist 检查
containsDangerousOperations() 是一个 denylist 检查器,拒绝以下危险操作:
| 危险操作 | 示例 | 风险 |
|---------|------|------|
| w/W 命令 | `sed 'w /etc/passwd'` | 写入任意文件 |
| e/E 命令 | `sed 'e rm -rf /'` | 执行 shell 命令 |
| 非 ASCII 字符 | `sed 's/\x{200B}//'` | Unicode 混淆攻击 |
| 花括号 | `sed 's/{a,b}/x/'` | 花括号扩展 |
| 换行符 | `sed 'a\nmalicious'` | 命令注入 |
| 注释 | `sed '# comment\ne evil'` | 注释隐藏命令 |
| 否定符 | `sed '!d'` | 否定语义混淆 |
| 反斜杠技巧 | `sed 's/\\\n//g'` | 行继续符混淆 |
| y 命令 | `sed 'y/abc/xyz/'` | 字符转换(无安全风险但限制) |
6.3 sedCommandIsAllowedByAllowlist:双模式入口
typescript
function sedCommandIsAllowedByAllowlist(
argv: string[],
allowFileWrites: boolean, // acceptEdits 模式允许 -i
): boolean {
// allowFileWrites=false: 只允许 Pattern 1 和 Pattern 2
// allowFileWrites=true: 额外允许 -i (in-place 编辑)
}
6.4 Sed 模拟执行(原书 4.7.2 节)
原书 4.7.2 节描述了 Sed 模拟执行。源码验证了这一设计------当 sed 命令包含 -i(in-place 编辑)时,BashTool 不直接执行 sed,而是:
- 用
sedEditParser.ts解析 sed 表达式 - 用
sedReplacementToJsReplacement()将 sed 替换模式转换为 JavaScript 正则替换 - 读取目标文件内容
- 在 JavaScript 中执行替换
- 用
writeTextContent()写回文件
这种"模拟执行"确保了"所见即所得"------JavaScript 正则替换的行为完全确定,不会受到 sed 实现差异或 sed 高级特性(如保持空间、分支)的影响。
7. Layer 6: 只读命令自动放行 --- readOnlyValidation.ts
7.1 多组只读命令白名单
readOnlyValidation.ts(76.3KB)维护了多组只读命令白名单:
| 白名单组 | 命令示例 | 来源 |
|---------|---------|------|
| GIT_READ_ONLY_COMMANDS | git status, git log, git diff, git show | Git 只读子命令 |
| GH_READ_ONLY_COMMANDS | gh pr list, gh issue view | GitHub CLI 只读子命令 |
| RIPGREP_READ_ONLY_COMMANDS | rg --json, rg --count | ripgrep 只读 flag |
| PYRIGHT_READ_ONLY_COMMANDS | pyright --outputjson | Pyright 只读模式 |
| DOCKER_READ_ONLY_COMMANDS | docker ps, docker images, docker logs | Docker 只读子命令 |
| EXTERNAL_READONLY_COMMANDS | (用户自定义) | 外部配置 |
7.2 validateFlags:flag 白名单验证
每组只读命令不仅有命令名白名单,还有 flag 白名单:
typescript
function validateFlags(
flags: string[],
allowedFlags: Set<string>,
): boolean {
// 只允许白名单中的 flag
// 防止 `git log --output=/etc/passwd` 类注入
}
7.3 containsVulnerableUncPath:UNC 路径漏洞检测
typescript
function containsVulnerableUncPath(args: string[]): boolean {
// 检测 Windows UNC 路径(\\server\share)
// 某些命令对 UNC 路径处理存在安全漏洞
}
8. Layer 7: 分类器审批与沙箱决策
8.1 分类器集成
当命令通过前六层但未被任何 allow 规则匹配时,进入分类器审批流程:
typescript
// bashPermissions.ts
if (feature('BASH_CLASSIFIER') && isClassifierPermissionsEnabled()) {
const classifierResult = classifyBashCommand(input.command)
// 分类器返回 allow/deny/ask + 置信度 + 匹配描述
// ...
pendingClassifierCheck: buildPendingClassifierCheck(
input.command,
appState.toolPermissionContext,
)
}
分类器(bashClassifier.ts)使用 LLM 对命令进行分类,返回 ClassifierResult:
typescript
type ClassifierResult = {
matches: boolean // 是否匹配某个分类器规则
matchedDescription: string | null // 匹配的描述
confidence: number // 置信度
reason: string // 决策原因
}
8.2 shouldUseSandbox:沙箱决策
shouldUseSandbox.ts(154 行)决定命令是否在沙箱中执行:
typescript
export function shouldUseSandbox(input: { command: string }): boolean {
// 1. 沙箱是否启用
if (!SandboxManager.isSandboxingEnabled()) return false
// 2. 用户是否显式禁用沙箱
if (input.dangerouslyDisableSandbox) return false
// 3. 命令是否在用户排除列表中
if (containsExcludedCommand(input.command)) return false
return true
}
containsExcludedCommand() 使用与 stripSafeWrappers 相同的固定点迭代策略来匹配排除命令:
typescript
function containsExcludedCommand(command: string): boolean {
const excludedCommands = getExcludedCommands() // 用户配置
// 固定点迭代剥离 env vars + wrappers
let stripped = command
let prev = ''
while (stripped !== prev) {
prev = stripped
stripped = stripAllLeadingEnvVars(stripped, BINARY_HIJACK_VARS)
stripped = stripSafeWrappers(stripped)
}
// 检查剥离后的命令是否匹配排除列表
return excludedCommands.some(pattern => matchPattern(stripped, pattern))
}
注意这里使用了 stripAllLeadingEnvVars(激进剥离)配合 BINARY_HIJACK_VARS 阻止列表。注释说明沙箱排除列表"not a security boundary --- permission prompts are"------它只是用户体验优化,真正的安全边界是权限提示。
8.3 沙箱技术概述(原书 4.11 节)
原书 4.11 节提到两种沙箱技术:
- Linux :
bubblewrap--- 命名空间隔离 - macOS :
Seatbelt(sandbox-exec) --- 系统调用过滤
沙箱限制了文件系统访问范围(只允许项目目录)和网络访问,即使命令执行了恶意操作,也无法访问项目外的文件或外发数据。
9. bashSecurity.ts:23 种安全检查
9.1 安全检查 ID 枚举
bashSecurity.ts 定义了 23 种 BASH_SECURITY_CHECK_IDS:
| 检查 ID | 检测内容 | 风险等级 |
|---------|---------|---------|
| INCOMPLETE_COMMANDS | 以 tab/flag/操作符开头的残缺命令 | 中 |
| COMMAND_SUBSTITUTION | $() / `` / ${} / $[] 命令替换 | 高 |
| PROCESS_SUBSTITUTION | <() >() =() 进程替换 | 高 |
| ZSH_DANGEROUS_COMMANDS | zmodload/emulate/sysopen/zpty 等 15 个 | 高 |
| ZSH_EXPANSION | Zsh 扩展语法 (~[]/=cmd) | 高 |
| JQ_SYSTEM_FUNCTION | jq 的 system() 函数调用 | 高 |
| OBFUSCATED_FLAGS | 混淆的 flag(如 --${var}) | 中 |
| SHELL_METACHARACTERS | ; | & && || 等 shell 元字符 | 中 |
| DANGEROUS_VARIABLES | IFS/PS1/PROMPT_COMMAND 等危险变量 | 高 |
| IFS_INJECTION | IFS 变量注入 | 高 |
| BRACE_EXPANSION | {a,b,c} 花括号扩展 | 中 |
| CONTROL_CHARACTERS | \x00-\x1f 控制字符 | 高 |
| UNICODE_WHITESPACE | \u00A0 等 Unicode 空白 | 高 |
| MID_WORD_HASH | 词中 # 号(注释混淆) | 中 |
| COMMENT_QUOTE_DESYNC | 注释与引号不同步 | 中 |
| ... | (共 23 种) | |
9.2 COMMAND_SUBSTITUTION_PATTERNS:11 种替换模式
typescript
const COMMAND_SUBSTITUTION_PATTERNS = [
/\$\([^)]*\)/, // $(cmd) --- 命令替换
/`[^`]*`/, // `cmd` --- 旧式命令替换
/\$\{[^}]*\}/, // ${var} --- 参数扩展(可能包含命令)
/\$\[[^]]*\]/, // $[expr] --- 算术扩展
/\(\)/, // () --- 子 shell
/<\([^)]*\)/, // <(cmd) --- 进程替换(输入)
/>\([^)]*\)/, // >(cmd) --- 进程替换(输出)
/=\([^)]*\)/, // =(cmd) --- Zsh 进程替换
/~\[[^\]]*\]/, // ~[expr] --- Zsh 动态目录
// ... 共 11 种模式
]
9.3 isSafeHeredoc:安全 heredoc 的精确验证
bashSecurity.ts 第 317 行的 isSafeHeredoc() 是一个极其精细的函数------它是 EARLY-ALLOW 路径 ,返回 true 会绕过所有后续验证器。因此它的检查必须可证明安全(provable safe),而非"可能安全"。
唯一允许的模式是:
[prefix] $(cat <<'DELIM'
[body lines]
DELIM
) [suffix]
关键约束:
- 分隔符必须单引号引用(
'DELIM')或转义(\DELIM),确保 body 是字面文本 - 关闭分隔符必须独占一行 (或仅尾部空白 +
)) - 关闭分隔符必须是第一个匹配行(精确复制 bash 行为)
$(前必须有非空白前缀(确保在参数位置,不是命令名位置)- 剥离 heredoc 后的剩余文本只允许安全字符
函数使用逐行匹配 (非正则 [\s\S]*?)来精确复制 bash 的 heredoc 关闭行为,防止正则跨行匹配跳过第一个分隔符。
9.4 stripSafeRedirections:安全重定向剥离
typescript
function stripSafeRedirections(command: string): string {
// 剥离安全重定向:2>&1, >/dev/null, </dev/null
// 需要尾部边界防止前缀匹配攻击
// `2>&1; rm -rf /` 不应被剥离为 `; rm -rf /`
}
10. 命令语义解释 --- commandSemantics.ts
10.1 exit code 语义
commandSemantics.ts(141 行)是 exit code 的语义解释器------某些命令的非零退出码不代表错误:
typescript
const COMMAND_SEMANTICS = new Map<string, CommandSemantic>([
['grep', { exitCode1IsError: false, message: 'No matches found' }],
['rg', { exitCode1IsError: false, message: 'No matches found' }],
['find', { exitCode1IsError: false, message: 'Some paths could not be searched' }],
['diff', { exitCode1IsError: false, message: 'Files differ' }],
['test', { exitCode1IsError: false, message: 'Condition evaluated to false' }],
// ...
])
10.2 interpretCommandResult
typescript
function interpretCommandResult(
command: string,
exitCode: number,
): { isError: boolean; message?: string } {
const baseCmd = heuristicallyExtractBaseCommand(command)
const semantic = COMMAND_SEMANTICS.get(baseCmd)
if (semantic && exitCode === 1 && !semantic.exitCode1IsError) {
return { isError: false, message: semantic.message }
}
return { isError: exitCode !== 0 }
}
heuristicallyExtractBaseCommand() 提取最后一条命令的基命令名------对于复合命令 cd /tmp && grep "test" file.txt,提取的是 grep 而非 cd。
11. 破坏性命令警告 --- destructiveCommandWarning.ts
11.1 15 种破坏性模式
destructiveCommandWarning.ts(103 行)检测 15 种破坏性命令模式:
| 类别 | 模式 | 正则 |
|------|------|------|
| Git | git reset --hard | /git\s+reset\s+--hard/ |
| Git | git push --force | /git\s+push\s+(-f|--force)/ |
| Git | git clean -f | /git\s+clean\s+-[a-z]*f/ |
| Git | git checkout . | /git\s+checkout\s+\./ |
| Git | git stash drop | /git\s+stash\s+drop/ |
| Git | git branch -D | /git\s+branch\s+-D/ |
| Git | git --no-verify | /git\s+.*--no-verify/ |
| Git | git commit --amend | /git\s+commit\s+--amend/ |
| 文件 | rm -rf | /rm\s+-[a-z]*r[a-z]*f/ |
| 文件 | rm -r | /rm\s+-[a-z]*r/ |
| 文件 | rm -f | /rm\s+-[a-z]*f/ |
| 数据库 | DROP TABLE | /DROP\s+TABLE/i |
| 数据库 | TRUNCATE TABLE | /TRUNCATE\s+TABLE/i |
| 数据库 | DELETE FROM | /DELETE\s+FROM/i |
| 基础设施 | kubectl delete | /kubectl\s+delete/ |
| 基础设施 | terraform destroy | /terraform\s+destroy/ |
11.2 纯信息性提示
typescript
export function getDestructiveCommandWarning(
command: string,
): { isDestructive: boolean; warning?: string } {
for (const pattern of DESTRUCTIVE_PATTERNS) {
if (pattern.regex.test(command)) {
return {
isDestructive: true,
warning: pattern.warning,
}
}
}
return { isDestructive: false }
}
关键设计 :破坏性命令警告是纯信息性的,不影响权限逻辑。即使命令匹配破坏性模式,权限决策仍然由七层防御链独立决定。这确保了关注点分离------安全决策和用户提醒是独立的。
12. 模式验证 --- modeValidation.ts
12.1 acceptEdits 模式自动放行
modeValidation.ts(116 行)定义了 acceptEdits 模式下自动放行的 7 种文件系统命令:
typescript
const ACCEPT_EDITS_ALLOWED_COMMANDS = new Set([
'mkdir', // 创建目录
'touch', // 创建空文件
'rm', // 删除文件(仍受 pathValidation 约束)
'rmdir', // 删除空目录
'mv', // 移动/重命名
'cp', // 复制
'sed', // 流编辑(仍受 sedValidation 约束)
])
12.2 checkPermissionMode
typescript
export function checkPermissionMode(
input: { command: string },
context: ToolUseContext,
): PermissionResult {
const mode = context.getAppState().permissionMode
// bypassPermissions 和 dontAsk 模式跳过
if (mode === 'bypassPermissions' || mode === 'dontAsk') {
return { behavior: 'passthrough', message: 'Mode skips permission check' }
}
// acceptEdits 模式:检查命令是否在允许列表中
if (mode === 'acceptEdits') {
const baseCmd = extractBaseCommand(input.command)
if (ACCEPT_EDITS_ALLOWED_COMMANDS.has(baseCmd)) {
return {
behavior: 'allow',
decisionReason: { type: 'mode', mode: 'acceptEdits' },
}
}
}
return { behavior: 'passthrough', message: 'No mode override' }
}
注意:即使 acceptEdits 模式自动放行 rm,路径约束(Layer 4)和 Sed 约束(Layer 5)仍然生效。模式验证只是跳过了分类器审批(Layer 7),前五层防御不受影响。
13. AsyncGenerator 流式执行(原书 4.7.3 节)
13.1 runShellCommand:2 秒阈值的进度轮询
原书 4.7.3 节描述了 AsyncGenerator 驱动的流式执行。BashTool.tsx 中的 runShellCommand 实现了这一设计:
执行流程:
1. 立即启动子进程
2. 等待 2 秒
├─ 2 秒内完成 → 直接返回结果
└─ 2 秒后未完成 → 进入进度轮询循环
3. 进度轮询循环
├─ 每秒检查进程状态
├─ yield 进度事件({ type: 'progress', message: 'Still running...' })
└─ 检查用户是否按 Ctrl+B
4. Ctrl+B 热切换
├─ 前台进程 → 后台 LocalShellTask
├─ 释放终端控制权
└─ 用户可继续输入其他命令
5. 进程结束
├─ 收集 stdout/stderr
├─ 应用 commandSemantics 解释 exit code
└─ 返回最终结果
13.2 AsyncGenerator 的设计优势
使用 AsyncGenerator(async function*)而非 Promise 的优势:
typescript
async function* runShellCommand(
command: string,
options: ShellOptions,
): AsyncGenerator<ToolProgressData | ToolResult> {
// yield 进度事件 → UI 实时更新
// yield 最终结果 → 工具系统接收
}
- 流式进度:用户看到实时进度,而非等待 30 秒后突然出现结果
- 可中断:用户可以 Ctrl+C 中断,也可以 Ctrl+B 切换到后台
- 背压控制:Generator 的 pull 模型天然支持背压
14. 命令清洗的完整流程图
将所有防御层串联起来,一条命令从输入到执行的完整清洗流程:
用户输入命令
│
▼
┌─ bashToolHasPermission() ─────────────────────────────────────┐
│ │
│ 1. Tree-sitter AST 解析 │
│ parseCommandRaw() → parseForSecurityFromAst() │
│ ├─ 预检查(控制字符/Unicode/反斜杠/Zsh/花括号) │
│ ├─ PARSE_ABORTED → fail-closed (ask) │
│ └─ checkSemantics() → eval/exec/source 检查 │
│ │
│ 2. 模式验证 │
│ checkPermissionMode() │
│ ├─ acceptEdits: mkdir/touch/rm/... 自动放行 │
│ └─ bypassPermissions/dontAsk: 跳过 │
│ │
│ 3. 沙箱自动放行 │
│ shouldUseSandbox() → checkSandboxAutoAllow() │
│ ├─ 沙箱启用 + 命令适合沙箱 → allow │
│ └─ 命中 deny/ask 规则 → 不放行 │
│ │
│ 4. 精确匹配 │
│ bashToolCheckExactMatchPermission() │
│ ├─ deny → 拒绝 │
│ ├─ ask → 询问用户 │
│ └─ allow → 放行 │
│ │
│ 5. 前缀/通配符匹配 │
│ bashPermissionRule() + matchWildcardPattern() │
│ ├─ deny: stripAllLeadingEnvVars (激进剥离) │
│ │ 匹配 deny 规则 → 拒绝 │
│ ├─ ask: stripAllLeadingEnvVars (激进剥离) │
│ │ 匹配 ask 规则 → 询问用户 │
│ └─ allow: stripSafeWrappers (保守剥离) │
│ 匹配 allow 规则 → 放行 │
│ │
│ 6. 路径约束 │
│ checkPathConstraints() │
│ ├─ validateSinglePathCommandArgv() (AST argv) │
│ ├─ checkDangerousRemovalPaths() (rm/rmdir 危险路径) │
│ ├─ validateOutputRedirections() (重定向目标) │
│ └─ compoundCommandHasCd() (cd+write 组合) │
│ │
│ 7. Sed 约束 │
│ checkSedConstraints() │
│ ├─ sedCommandIsAllowedByAllowlist() (双模式白名单) │
│ └─ containsDangerousOperations() (denylist) │
│ │
│ 8. 只读自动放行 │
│ validateReadOnlyCommand() │
│ ├─ GIT/GH/RIPGREP/PYRIGHT/DOCKER 白名单 │
│ ├─ validateFlags() (flag 白名单) │
│ └─ containsVulnerableUncPath() (UNC 漏洞) │
│ │
│ 9. 分类器审批 │
│ classifyBashCommand() + pendingClassifierCheck │
│ ├─ 分类器 allow → 放行 │
│ ├─ 分类器 deny → 拒绝 │
│ └─ 分类器 ask / 无分类器 → 询问用户 │
│ │
│ 10. 破坏性命令警告(不影响决策) │
│ getDestructiveCommandWarning() │
│ └─ 附带警告信息到结果中 │
│ │
└────────────────────────────────────────────────────────────────┘
│
▼
执行 / 拒绝 / 询问用户
│
▼ (如果执行)
runShellCommand() AsyncGenerator
├─ 启动子进程
├─ 2s 阈值 → 进度轮询
├─ Ctrl+B → 后台执行
├─ commandSemantics 解释 exit code
└─ 返回结果
15. 原书对照验证表
| 原书描述 | 源码验证 | 一致性 | 补充说明 |
|---|---|---|---|
| 七层安全防御流水线 | bashToolHasPermission() 中的 10 步检查链 |
✅ 一致 | 源码实际有 10 步,比原书 7 层更细粒度 |
| Layer 1: AST 解析 | parseForSecurityFromAst() + checkSemantics() |
✅ 一致 | 源码有六道预检查 + PARSE_ABORTED 对抗处理 |
| Layer 2: 精确匹配 | bashToolCheckExactMatchPermission() |
✅ 一致 | --- |
| Layer 3: 前缀/通配符 | bashPermissionRule() + matchWildcardPattern() |
✅ 一致 | deny/allow 不对称剥离是源码独有的精细设计 |
| Layer 4: 路径检查 | checkPathConstraints() |
✅ 一致 | 35 种 PathCommand + -- 处理 + cd+write 组合检测 |
| Layer 5: Sed 约束 | checkSedConstraints() |
✅ 一致 | 双模式白名单 + denylist + 模拟执行 |
| Layer 6: 只读放行 | validateReadOnlyCommand() |
✅ 一致 | 6 组白名单 + flag 验证 + UNC 检测 |
| Layer 7: 分类器审批 | classifyBashCommand() |
✅ 一致 | GrowthBook Feature Flag 灰度控制 |
| 固定点迭代命令清洗 | stripSafeWrappers() 两阶段 while 循环 |
✅ 一致 | Phase 1 剥离 env vars,Phase 2 剥离 wrappers |
| Sed 模拟执行 | sedReplacementToJsReplacement() + writeTextContent() |
✅ 一致 | 确保"所见即所得" |
| AsyncGenerator 流式执行 | runShellCommand() async function* |
✅ 一致 | 2s 阈值 + 进度轮询 + Ctrl+B 热切换 |
| exit code 语义解释 | commandSemantics.ts |
✅ 一致 | grep/rg(1=无匹配)/find(1=部分成功)/diff(1=有差异) |
| 破坏性命令警告 | destructiveCommandWarning.ts |
✅ 一致 | 15 种模式,纯信息性不影响权限 |
| bubblewrap(Linux)/Seatbelt(macOS) | SandboxManager |
✅ 一致 | 原书 4.11 节描述 |
| --- | stripAllLeadingEnvVars deny/allow 不对称 |
📝 源码独有 | 原书未详细描述 deny 激进 / allow 保守策略 |
| --- | isSafeHeredoc 精确 heredoc 验证 |
📝 源码独有 | 原书未详细描述 EARLY-ALLOW 路径 |
| --- | BARE_SHELL_PREFIXES 阻止危险前缀建议 |
📝 源码独有 | 原书未提及 |
| --- | MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50 |
📝 源码独有 | DoS 防护,原书未提及 |
| --- | Shadow mode 影子测试 | 📝 源码独有 | Tree-sitter 灰度发布的安全验证机制 |
16. 设计哲学总结
16.1 Fail-Closed 原则
整个七层防御链贯穿 Fail-Closed 原则:
- AST 解析失败 →
too-complex→ ask(不放行) - Tree-sitter 超时 →
PARSE_ABORTED→ ask(不降级到 legacy) - 语义检查遇到未知 flag → fail closed(不 fall-through)
- timeout duration 格式不匹配 → fail closed(不 fall-through)
- nice 参数包含扩展 → fail closed(不 fall-through)
16.2 纵深防御
七层防御不是冗余------每层防御覆盖不同的攻击向量:
- Layer 1 防御解析层攻击(Tree-sitter/bash 差异)
- Layer 2-3 防御权限规则绕过(env var/wrapper 混淆)
- Layer 4 防御文件系统攻击(路径遍历/危险目录)
- Layer 5 防御Sed 命令注入(e 命令/w 写入)
- Layer 6 防御flag 注入(--output=/etc/passwd)
- Layer 7 防御未知命令(分类器 LLM 判断)
16.3 不对称安全边界
deny 和 allow 使用不同强度的剥离策略,体现了"安全边界不对称"的设计:
- deny 不可绕过:激进剥离所有 env vars,确保 deny 规则不会被 env var 前缀绕过
- allow 不可扩大 :保守剥离已知安全 env vars,防止
DOCKER_HOST=evil类攻击通过 allow 规则
16.4 可观测性优先
每个安全检查都通过 logEvent('tengu_bash_security_check_triggered', ...) 记录遥测,包括:
checkId:触发的检查类型subId:子类型(如 INCOMPLETE_COMMANDS 有 3 个子类型)nodeTypeId:AST 节点类型(用于 too-complex 分析)
这些遥测数据用于安全审计和防御策略调优。
16.5 与其他框架对比(原书 4.10 节)
原书 4.10 节将 Claude Code 的 BashTool 安全与其他框架对比:
| 框架 | 命令安全策略 | 局限性 |
|------|------------|--------|
| LangChain | 无内置安全 | 完全依赖开发者 |
| OpenAI Code Interpreter | Python 沙箱 | 不支持 shell 命令 |
| Claude Code | 七层防御 + Tree-sitter AST | 复杂但全面 |
| Vercel AI SDK | 无内置安全 | 完全依赖开发者 |
Claude Code 的七层防御链是当前 AI 编程工具中最复杂的命令安全实现,没有之一。
17. 验证清单
- BashTool 是否真的有 7 层防御? → 是,源码实际有 10 步检查,对应原书 7 层
- 命令验证如何实现? → Tree-sitter AST 解析 + 六道预检查 + 语义检查
- 路径检查如何实现? → 35 种 PathCommand 路径提取 +
--处理 + 危险路径检测 + cd+write 组合检测 - Shell 注入如何防御? → 命令替换/进程替换/Zsh扩展/IFS注入/Unicode空白等 23 种安全检查
- 超时如何处理? → AsyncGenerator + 2s 阈值 + 进度轮询 + Ctrl+B 热切换
- 沙箱如何决策? →
shouldUseSandbox()三步检查(启用/禁用/排除) - 权限如何决策? → 精确匹配 → 前缀/通配符 → deny/ask/allow 不对称剥离
- 输出如何截断? →
maxResultSizeChars限制 + 流式进度推送 - 固定点迭代如何实现? →
stripSafeWrappers两阶段 while 循环直到收敛 - Sed 模拟执行如何实现? →
sedReplacementToJsReplacement()+writeTextContent()
下一篇:4.4-4.6 工具执行管线 --- 九步执行与并发编排