深扒 Claude Code 源码(二):工具抽象与 Bash 安全引擎
工具执行管线 / fail-closed 默认 / tree-sitter AST 权限引擎
本文是《深扒 Claude Code 源码》系列第二篇,聚焦工具抽象与 Bash 安全。承接第一篇·会话内核。所有论点带
file:line证据。
4. 工具抽象与执行管线
本章剖析 Claude Code 的工具层:从 Tool 接口的形状与能力位、buildTool 的 fail-closed 默认值,到 checkPermissionsAndCallTool 的完整执行管线,再到两条并发调度路径(批处理 runTools 与边流边执行的 StreamingToolExecutor),以及 assembleToolPool 为 prompt cache 稳定性所做的设计。所有工具都遵循同一抽象契约,权限/校验/落盘逻辑集中在执行管线,工具本身只负责声明能力与执行业务逻辑。
4.1 Tool 接口的形状与能力位
Tool 是一个宽接口(src/Tool.ts:362-695),泛型为 Tool<Input, Output, P>,把"模型可调用的一段能力"拆成三类成员:
(1) 核心执行与 schema:call(args, context, canUseTool, parentMessage, onProgress) 返回 Promise<ToolResult<Output>>;inputSchema(Zod)是唯一必填的校验源;MCP 工具可用 inputJSONSchema 直接给 JSON Schema。ToolResult<Output> 除 data 外可携带 newMessages(注入消息流)、contextModifier(仅对非并发安全工具生效)、mcpMeta。
(2) 能力位(capability bits)——管线据此做调度与安全决策:
isConcurrencySafe(input):决定该次调用能否与其它并发安全调用并行。注意它是 per-input 的,不是 per-tool。Bash 直接复用只读判定(return this.isReadOnly?.(input) ?? false),即"只读才并发安全"。isReadOnly(input):Bash 对每个命令做checkReadOnlyConstraints静态分析——ls/grep只读、rm/git push非只读。isDestructive?(input):仅当执行不可逆操作(删除/覆盖/发送)时置 true。interruptBehavior?():返回'cancel'(用户发新消息时停掉并丢弃结果)或'block'(继续跑、新消息等待),默认'block'。这是 StreamingToolExecutor 在用户中断时判断该不该 kill 某工具的依据。shouldDefer?/alwaysLoad?:shouldDefer=true的工具以defer_loading:true发送,模型必须先用 ToolSearch 才能调用;alwaysLoad=true强制完整 schema 进首轮 prompt。maxResultSizeChars:结果超阈值就落盘。Infinity是硬退出(Read 用它,因为把 Read 的输出落盘再被 Read 读回是循环)。Bash 声明30_000。
(3) 模型视图 / UI 视图分离——这是工具层一个明确的设计决策。mapToolResultToToolResultBlockParam 把 Output 映射成 发给模型的 block(带 system-reminder、<persisted-output> 包裹);而 renderToolResultMessage 渲染 给终端用户看的 Ink 节点。两者刻意分开:注释专门强调,转录搜索索引必须用 UI 视图渲染出的文本,绝不能用模型序列化(那个加了 chrome),否则会出现"索引了但屏幕没渲染"的 phantom bug。
4.2 buildTool / TOOL_DEFAULTS 与 fail-closed 默认值
所有工具都经 buildTool(def) 构造,运行时就是 { ...TOOL_DEFAULTS, userFacingName, ...def }。关键在 TOOL_DEFAULTS(Tool.ts:757-769)的 fail-closed 取向:
isConcurrencySafe → false(假设不安全,宁可串行);isReadOnly → false(假设有写);isDestructive → false;checkPermissions → { behavior:'allow' }(注意这一项是 fail-open 的:默认放行,把决策让给 permissions.ts 的通用系统);toAutoClassifierInput → ''(默认跳过 auto-mode 安全分类器——安全相关工具必须自己 override)。
工程含义:一个工具开发者若忘了声明并发/只读,系统会保守地把它当"有写、不可并发"对待(绝不会误判成可并行的写操作);但权限默认放行交给集中式 permissions.ts,安全分类器默认不参与——这两项是"显式 override 才生效"的安全开关。
4.3 checkPermissionsAndCallTool 完整执行管线
真正的管线是 checkPermissionsAndCallTool(toolExecution.ts:599),严格顺序:
- Zod safeParse:失败时调
buildSchemaNotSentHint——若该工具是 deferred 但未被 ToolSearch 发现,Zod 报"expected array got string"这类错对模型没用,于是追加提示:“你的 schema 没发给 API,先select:${tool.name}再重试”。这是延迟加载与类型化参数的衔接点。 - validateInput:可选的工具特定值校验,失败产出
<tool_use_error>并带errorCode。 - Bash 投机分类器:提前
startSpeculativeClassifierCheck让 allow 分类器与 hooks、deny/ask 分类器、权限对话框搭建并行跑——但不在此设置 UI 指示器(避免对前缀规则自动放行的命令闪烁"分类器运行中")。 - 输入处理与 backfill:先防御性剥掉模型不该提供的
_simulatedSedEdit;再在 浅克隆 上调backfillObservableInput(给 hooks/canUseTool 看派生字段,但不污染tool.call()看到的输入——因为文件工具会用expandPath改写file_path,改了会破坏转录/VCR fixture 哈希)。 - PreToolUse hooks:结果按 type 分发——
hookPermissionResult、hookUpdatedInput、preventContinuation、stop等。 - 权限决策:经
resolveHookPermissionDecision统一收口。其逻辑:hook 若allow,仍要跑checkRuleBasedPermissions——deny 规则覆盖 hook 放行、ask 规则强制弹窗;hookdeny直接拒。决策后发tool_decisionOTel 事件。 - call 执行:
startSessionActivity('tool_exec')后await tool.call(...)。 - 结果落盘与 PostToolUse:关键是
mapToolResultToToolResultBlockParam只调一次 并缓存。结果消息对 subagent 默认剥掉toolUseResult/mcpMeta。 - 错误路径:MCP auth 错误把 client 状态改
needs-auth,非 AbortError 才 logError 并classifyToolError(把 minified 的error.constructor.name换成 errno code 或稳定.name)。
4.4 两条调度路径
路径 A:toolOrchestration 批处理。partitionToolCalls 用 reduce 把工具调用序列切成批:连续的并发安全工具合成一批,其余每个单独成批。判定 isConcurrencySafe 时抛异常(如 shell-quote 解析失败)保守地当不安全。并发批走 all(generators, getMaxToolUseConcurrency()) 控并发,上限默认 10(可经 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 覆盖)。
路径 B:StreamingToolExecutor 边流边执行。工具块还在从 API 流入时就开始执行。canExecuteTool 规则是"没有正在执行的工具,或本工具并发安全且所有在执行的也都并发安全"——即并发安全工具可成群并行,非并发安全工具必须独占。
其精髓是 Bash 报错 siblingAbort 级联取消:当某工具产出 error 结果时,只有 Bash 会 siblingAbortController.abort('sibling_error')——因为 Bash 命令常有隐式依赖链(mkdir 失败则后续命令无意义),而 Read/WebFetch 等相互独立,一个失败不该殃及其余。级联后尚未运行的兄弟工具产出合成错误 Cancelled: parallel tool call X errored,正在跑的 Bash 子进程立即被 kill。另一方面,非 sibling_error 的 abort(权限拒绝、用户中断)会向上冒泡到 query 控制器(注释引用回归 #21056)。
4.5 assembleToolPool、deny 前置过滤与 ToolSearch 延迟加载
deny 规则前置过滤:filterToolsByDenyRules 用与运行时权限检查同一个 matcher剔除被 blanket-deny 的工具——所以 mcp__server 这类服务器前缀 deny 规则会在模型看到工具之前就把该服务器全部工具剥掉。这是把权限拒绝从调用期提前到工具枚举期,既省 token 又防止模型尝试不可用工具。
assembleToolPool 的排序+uniqBy(tools.ts:345-367):两个分区各自按 name localeCompare 排序,内置工具作为连续前缀、MCP 工具接在后面,最后 uniqBy(..., 'name')。注释讲清了为什么不做扁平排序:服务器的 claude_code_system_cache_policy 在最后一个前缀匹配的内置工具后放全局缓存断点;若扁平排序会把 MCP 工具插进内置工具之间,导致每当某 MCP 工具排序落在已有内置工具中间时,所有下游缓存键失效。这是整个工具层为 prompt cache 稳定性所做的最关键设计。
ToolSearch 延迟加载:启用时 extractDiscoveredToolNames(messages) 从历史里的 tool_reference 块提取已发现的工具名,filteredTools 只保留"非 deferred 工具 + ToolSearchTool 本身 + 已发现的 deferred 工具"——即 deferred 工具的完整 schema 只在模型经 ToolSearch 发现它之后才发给 API,消除了预声明全部 deferred 工具的需要和工具数量上限。
大输出 <persisted-output> 落盘:先处理空结果(注入 (toolName completed with no output),因为空 tool_result 在 prompt 尾部会让某些模型误判 turn 边界提前停,见 inc-4586),然后落盘路径用 flag:'wx'——因 tool_use_id 唯一且内容确定,已存在就跳过,防止 microcompact 重放原始消息时每轮重写同一文件。enforceToolResultBudget 用 ContentReplacementState 按 tool_use_id 冻结历史决策保证 prompt cache 前缀逐轮字节一致。
5. Bash 安全与权限引擎
Bash 工具的权限引擎是 Claude Code 整个 agent 沙盒之外最重要的一道"软"防线:它在不真正约束进程的前提下,回答一个唯一的问题——"我能不能为这条命令产出一份可信的 argv,从而把它和权限规则、只读白名单逐字匹配?"如果能,按规则放行/拒绝;如果不能,一律降级为 ask 交给人类。核心实现在 bashPermissions.ts(2621 行)的 bashToolHasPermission。
5.1 tree-sitter parseForSecurity:fail-closed 的 AST 分类器
入口 bashToolHasPermission 做的第一件事是 AST 解析:parseCommandRaw(input.command) 用 tree-sitter-bash WASM 把命令解析成语法树,输出三种结果之一:simple(干净解析,拿到 argv 已去引号的 SimpleCommand)、too-complex(碰到命令替换、进程替换、控制流、子 shell 等无法静态分析的结构)、parse-unavailable(回落到旧 shell-quote 路径)。
walker 的设计哲学是显式 allowlist + fail-closed:collectCommands 对每种已知节点类型分支处理,任何没被显式 handle 的节点类型一律走到 tooComplex(node)。安全性不依赖"枚举所有危险结构",而依赖"枚举所有已知安全结构,其余全部不信任"。
解析前还跑一批"预检"正则,专门捕捉 tree-sitter 与 bash 分词差异这类 walker 看不到的攻击面:Unicode 空白(NBSP/零宽/BOM)、反斜杠转义空白(cat\ test)、zsh =cmd EQUALS 展开(=curl evil.com 会被 zsh 展成 /usr/bin/curl,绕过 Bash(curl:*) deny)。PARSE_ABORTED(超时/节点预算/panic)被显式映射成 too-complex 而非 parse-unavailable,因为后者会回落到缺少 EVAL_LIKE_BUILTINS 检查的旧路径——这是一处对抗性触发器(约 2800 层算术下标可命中超时)的 fail-closed 修复。
变量作用域追踪是 walker 里最精细的部分。关键安全决策在分隔符语义:&&/; 是顺序执行,作用域线性传递;但 ||/|/& 的 RHS 可能不执行或在子 shell 里执行,因此必须快照并重置作用域,否则会有"flag 省略攻击"——true || FLAG=--dry-run && cmd $FLAG,bash 跳过 || RHS 使 $FLAG 为空、cmd 不带 --dry-run 运行,但线性作用域会误算成带 flag 从而看起来安全。
5.2 降级到 ask 但 deny 先行:防降级绕过
too-complex 是设计上的安全出口,但有致命陷阱:如果直接 return ask,那么一个被 deny 规则禁掉的命令,只要包一层命令替换就能从 deny 降级成 ask。引擎用 checkEarlyExitDeny 堵住它:在 too-complex 分支里,先调精确匹配,再查 prefix/wildcard deny 规则,只有当没有任何 deny 命中时才 fall through 到 ask。这是非常细的纵深防御点:deny 永远不能因为结构复杂或语义可疑而被降级。
值得注意的工程约束:这些函数都被抽成独立函数,注释反复强调是为了把 bashToolHasPermission 压在 Bun feature() DCE 的复杂度预算之下——一旦超阈值,Bun 无法把 feature('BASH_CLASSIFIER') 证明为常量,会静默把三元求值成 false,丢掉所有分类器注入。连 import { X as Y } 别名都算进预算。这是逆向源码里少见的、把构建期 DCE 行为当作硬约束来写代码的例子。
5.3 checkSemantics:tokenize 正常但按名字危险的命令
干净解析后调 checkSemantics。它先内联 wrapper 剥离逻辑(time/nohup/timeout/nice/env/stdbuf),把 nohup eval "..." 还原成被包裹的真实命令。每个 wrapper 的 flag 解析都是 fail-closed。剥到真实 argv[0] 后做一连串按名拦截:
EVAL_LIKE_BUILTINS:eval/source/exec/command/builtin/trap/enable/mapfile/hash 等——这些要么直接 eval 代码,要么把别的命令当 argv[0] 偷换(coproc rm -rf /的 argv[0] 是coproc,规则只会看到 coproc 而非 rm)。- 数组下标算术求值原语:bash 在
arr[EXPR]的下标里会算术求值,从而在单引号 raw_string 里也执行$(cmd):printf -v 'a[$(id)]'、test -v 'a[$(id)]'都被拦,而 tree-sitter 把这些当不透明叶子完全看不到。 - jq
system()与读文件/执行 flag;NEWLINE_HASH_RE(\n后接#会被下游当注释行,隐藏后续参数)。
5.4 规则模型:exact / prefix / wildcard 三类匹配
权限规则解析成 exact / prefix / wildcard 三类。两条关键安全规则贯穿匹配:(a) exact 模式下 wildcard 永不匹配,否则 foo * 会匹配 foo arg && curl evil.com,因为 .* 会吃掉操作符;(b) prefix/wildcard 规则不允许匹配复合命令,防止 cd src && python3 hello.py 经第一次 splitCommand 逃逸后伪装成单条命令。
matchingRulesForInput 对三类规则用不同强度:deny/ask 用 stripAllEnvVars:true(任意 env 前缀都剥),allow 不剥不安全 env——这是有意的非对称:allow 规则若剥掉 DOCKER_HOST=evil 就会让 DOCKER_HOST=evil docker ps 自动匹配 Bash(docker ps:*)(HackerOne #3543050),而 deny 规则必须更难绕过,被禁的命令加任何 env 前缀都得保持被禁。
5.5 安全加固手法
BINARY_HIJACK_VARS 剥离。 SAFE_ENV_VARS 是保守白名单,注释明确列出永不可加入的变量:PATH/LD_PRELOAD/LD_LIBRARY_PATH/DYLD_*(执行/库加载)、PYTHONPATH/NODE_PATH(模块加载)、NODE_OPTIONS(可塞代码执行 flag)。BINARY_HIJACK_VARS = /^(LD_|DYLD_|PATH$)/ 作为"会让另一个二进制运行"的黑名单。
stripSafeWrappers 的正则即安全。 用 [ \t]+ 而非 \s+,因为 \s 会跨 \n(命令分隔符),跨行剥离会把一行的 wrapper 剥掉而留下另一行命令;timeout 的 flag 值用 allowlist,因为旧的 [^ \t]+ 会让 timeout -k$(id) 10 ls 剥成 ls,而 bash 在 word-splitting 阶段就展开了 $(id)。BARE_SHELL_PREFIXES 禁止把 bash/sh/env/xargs/nice/sudo 建议成 prefix 规则,因为 Bash(nice:*) ≈ Bash(*)。
sed 拦截渲染成编辑 + _simulatedSedEdit schema omit。 这是设计最巧妙的一处。in-place sed 被渲染成 FileEdit 的样子,用户审批的是"编辑文件"的预览。审批后把预计算结果写进 _simulatedSedEdit。关键在于:该字段始终从模型可见的 schema 中 omit 掉。注释直说原因——若暴露给模型,它可以把一条无害命令配上任意文件写内容,绕过权限检查和沙盒实现任意写。这是"内部字段必须对模型不可见,否则即提权"的典型范例。
UNC 路径防 NTLM 泄漏。 仅 Windows 生效,检测 8 类 UNC 形态(反斜杠 \\server\share、正斜杠 //server、混合分隔符、WebDAV、IPv4/IPv6 UNC)。目的是阻止访问远程 UNC 路径触发 SMB/WebDAV 自动认证、把 NTLM/Kerberos 凭据哈希泄漏给攻击者主机。
5.6 BASH_CLASSIFIER 投机分类器 + TREE_SITTER_BASH_SHADOW 影子模式
BASH_CLASSIFIER 是 Haiku 驱动的分类器,与确定性规则引擎并行。投机预检是工程亮点:startSpeculativeClassifierCheck 把 allow 分类器的 Promise 提前塞进 Map,让它与 pre-tool hook、deny/ask 分类、权限对话框搭建并行;后续在权限提示框显示期间后台消费结果,若高置信 allow 且用户尚未交互就自动批准,把分类器延迟藏进用户阅读对话框的时间窗里。
TREE_SITTER_BASH_SHADOW 是在线验证新解析器的影子模式:它跑 tree-sitter 解析、记录 available/astTooComplex/astSemanticFail/subsDiffer 到遥测,然后 强制只观测、不生效,让旧的正则路径仍然是权威。工程意义在于:在把 tree-sitter 切成主路径之前,先用真实流量度量它与旧路径的分歧率,量化回归风险后再切换。这种"先影子、后切流"的渐进式上线,是把一个安全关键组件替换掉的负责任做法。
5.7 沙盒:诚实的非边界
shouldUseSandbox 决定一条命令是否包进 OS 级沙盒(macOS Seatbelt / Linux bubblewrap)。sandbox-adapter.ts 的硬编码 denyWrite 很有看点:始终禁止写 settings.json(防沙盒逃逸)、禁止写 .claude/skills、以及 bare-repo 文件 scrub——若 cwd 出现 HEAD+objects/+refs/+config,git 会把 cwd 当裸仓库并从中跑 hooks,攻击者植入这些文件加 core.fsmonitor 即可逃逸;不存在的记进 bareGitRepoScrubPaths,命令跑完在非沙盒 git 看到之前删除。
最诚实的一处是 excludedCommands 的注释:“excludedCommands is a user-facing convenience feature, not a security boundary. It is not a security bug to be able to bypass excludedCommands”——真正的安全控制是会弹窗提示用户的权限系统。这种把"便利特性"和"安全边界"严格区分、并在注释里写明哪些不是边界的做法,避免了把启发式当成保证的认知错误。
5.8 PowerShell:CLM 类型白名单 + gitSafety
Windows PowerShell 侧用一套独立但同构的机制。clmTypes.ts 借用 Microsoft 的 Constrained Language Mode 类型白名单做反向判定:任何不在白名单里的 [Type] 字面量 → ask,一次规范检查替代枚举所有危险类型。但 CC 比 MS 更严:主动移除了 adsi/adsisearcher/wmi/cimsession,因为它们在 cast 时会发起网络绑定——[adsi]'LDAP://evil.com/...' 连 LDAP——MS 允许是因为面向受信域内的管理员,而 CC 的目标未经校验。
gitSafety.ts 针对 bare-repo 攻击的 PowerShell 版,难点全在路径规范化:剥 PS 参数前缀、引号、provider 前缀 FileSystem::、drive-relative,再模拟 Win32 CreateFileW 的逐段 trailing 空格/点剥离(hooks .→hooks),最后归一化。注释明确点出 resolveEscapingPathToCwdRelative 是 bare-repo HEAD 攻击的唯一守卫,“do not remove without adding an alternative guard”。
5.9 权限决策顺序与输出截断
权限决策顺序固定:exact deny/ask → prefix deny/ask → 路径约束 → allow → sed 约束 → mode → 只读放行 → passthrough;deny/ask 必须在路径约束之前,否则项目目录外的绝对路径(ls /home)会因 checkPathConstraints 先返回 ask 而绕过 deny 规则(HackerOne)。MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50 是 ReDoS/事件循环饿死的硬上限(CC-643:splitCommand 在复合命令上可能指数膨胀,曾导致 REPL 100% CPU 冻结)。
输出侧:超过 getMaxOutputLength() 的输出落盘,超过 MAX_PERSISTED_SIZE = 64MB 先 truncate;还有零 token 侧信道处理:<claude-code-hint /> 标签从 stdout 扫出并 strip 掉、不让模型看到。
下一篇(三)进入服务层:API 客户端的四 provider 与 beta header 体系、MCP 集成的七种传输与 OAuth/XAA。
原文链接:https://www.ssssmy.com/notes/claude-code-deep-dive-tools