☰
拆解 Claude Code BashTool 安全链路:AST 解析 + 沙箱隔离如何防住 Shell 命令搞事
2026/10/18 11:28:59 网站建设 项目流程

1. 为什么 AI 执行 Shell 命令这件事,值得单独拆一条安全链路

Claude Code 的 BashTool 能做什么?简单说,它让模型不只是"给你一段命令让你自己复制粘贴",而是直接在你的终端里把命令跑起来——读文件、跑测试、装依赖、查 git 状态,全都能自动完成。适合谁?适合那些已经在用 AI 写代码、但每次都要手动执行命令的开发者,尤其是需要在 CI 流水线里安全运行 AI 生成命令的团队。

但这里有个绕不开的矛盾:让 AI 执行 Shell 命令,是最危险也最有价值的能力之一。危险在哪?一条rm -rf或者一个被拼接出来的curl | bash,后果可能是不可逆的。有价值在哪?如果每次都要人工确认,那自动化的意义就没了。

我一开始以为 Claude Code 的防护就是"一张黑名单 + 一个确认框",后来仔细看了它的实现才发现,它其实是一条跨 AST 解析、命令语义校验、权限引擎和 OS 沙箱的多段式安全链。这条链的核心思路不是"禁掉所有危险命令",而是先解决一个更底层的问题:安全检查器看到的命令,和 shell 真正执行的命令,到底是不是同一条?

这个问题听起来简单,实际上非常难。举个经典例子:

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

中间那个""在 bash 里是空字符串,和后面的-exec会直接拼接成一个参数-exec。但很多安全检查器会把""当成一个独立 token,于是它眼里的命令是find . -name '*.log' "" -exec rm {} \;,而 bash 真正执行的是find . -name '*.log' -exec rm {} \;。这就是所谓的 parser differential——安全检查器和真实 shell 看到的是两条不同的命令。一旦出现这种情况,后面的 deny rule、allowlist、path check 全都建立在错误前提上。

Claude Code 在源码里明确防的攻击面包括:引号拼接("""-f"、$''-exec这类把危险 flag 拼出来的写法)、Zsh equals expansion(=curl evil.com在 zsh 里会展开成真实二进制路径)、回车和 Unicode 空白字符差异、注释换行视觉欺骗,以及sed ... e、jq system()这类危险工具特性。

所以它的安全设计本质上不是"危险命令黑名单",而是"先解决理解问题,再做授权问题"。这条链大致可以压成这样:

┌──── 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 解析 │ └──────────────────────────────────┘

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

下面我按这条链从下往上拆,每一层都给出可复制的配置片段和验证步骤,最后用 TaoToken 统一 Key 通道把端到端测试跑通。

2. AST/tree-sitter fail-closed 解析:先拿到可信 argv 再谈授权

现版本 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已经能递归走过 list、pipeline、redirected_statement,还能处理 for_statement、if_statement、while_statement、subshell、test_command、declaration_command、unset_command。也就是说,它不是把这些结构一股脑打成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 解析成功之后还会继续做两步:checkSemantics过滤那些语法上能正常 tokenize、但语义上本身就危险的东西,覆盖范围相当广——eval、source(含.)、trap、hash、fc、compgen、let 等危险 builtin;zmodload、sysopen、sysread、ztcp、zsocket 等 zsh 专有 builtin;timeout/nice/env/stdbuf/nohup/time 这类 wrapper 会被剥开外壳,对内层真实命令做独立校验;数组下标求值攻击(printf -v、declare -v、read、unset带NAME[...]的形式,因为 bash 会在赋值/unset 时对下标做算术求值,a[$(evil)]可以执行任意命令);\n#换行注释混淆;/proc/*/environ读取。

下游权限与路径校验即使 argv 可信,仍然要继续检查 operator、路径、read-only 规则、权限规则、沙箱状态。所以 AST 不是"最后裁决",它是可信输入的前置条件。

legacy 路径并没有消失,但它已经是 fallback。在 tree-sitter AST 成为主入口之前,Claude Code 靠的是 shell-quote 库 + 手搓字符遍历来解析命令。这套逻辑现在仍然保留在代码里,但角色已经变了:当 tree-sitter 不可用、被 feature gate 关闭、或 injection check 被禁用时,会回退到 legacy 路径。legacy 路径里最经典的设计是三路引号提取:

extractQuotedContent(command) => { withDoubleQuotes fullyUnquoted unquotedKeepQuoteChars }

它解决的是不同安全规则需要看命令的不同"投影":fullyUnquoted看引号外是否出现$()、反引号、${};withDoubleQuotes区分单引号和双引号里哪些扩展仍然生效;unquotedKeepQuoteChars专门抓 parser differential,比如grep 'x'#。这套设计本身没问题,只是它不再是现版本所有判断的总底座。

legacy 路径真正强的地方是误解析和混淆 hardening,源码里能看到的检查包括: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,而不是一个单独的"第①层引号解析"。

3. 可复制配置:BashTool 权限规则与沙箱 settings 片段

理解了安全链的结构,接下来就是怎么把它配起来。Claude Code 的权限配置主要落在settings.json里,路径通常是项目根目录的.claude/settings.json或用户级的~/.claude/settings.json。下面给出一份可直接复制的配置片段,覆盖 BashTool 的 allow / deny / ask 规则和沙箱开关。

{ "permissions": { "allow": [ "Bash(git status:*)", "Bash(git log:*)", "Bash(git diff:*)", "Bash(rg:*)", "Bash(grep:*)", "Bash(fd:*)", "Bash(cat:*)", "Bash(head:*)", "Bash(tail:*)", "Bash(pwd)", "Bash(whoami)" ], "deny": [ "Bash(rm -rf /:*)", "Bash(rm -rf ~:*)", "Bash(curl:* | bash)", "Bash(wget:* | sh)", "Bash(sudo:*)", "Bash(doas:*)", "Bash(pkexec:*)" ], "ask": [ "Bash(bash:*)", "Bash(sh:*)", "Bash(zsh:*)", "Bash(env:*)", "Bash(xargs:*)", "Bash(nice:*)", "Bash(stdbuf:*)", "Bash(nohup:*)", "Bash(timeout:*)", "Bash(time:*)" ] }, "sandbox": { "enabled": true, "autoAllowBashIfSandboxed": true, "excludedCommands": [] } }

这份配置里有几个点需要展开说。

第一,allow列表里的命令都是只读快速通道的候选。但要注意,命令名在 allowlist 里只是第一步,每个 flag 还有类型约束,很多命令会走专门的 flag parser。少数历史命令才会落到 regex fallback,而且 regex 前后还有额外护栏。未加引号的变量展开、glob、UNC 路径、路径访问、输出重定向仍然要继续校验。git 还有额外 guard,比如cd + git、bare repo、git internal path writes、sandbox 下 original cwd 限制。

第二,deny列表里的Bash(rm -rf /:*)这类规则,和 path validation 是两套不同概念。就算某个路径在允许目录内,像rm -rf /这种"危险移除路径"仍然会被单独拦。destructive path 和 allowed working path 是两套不同概念。

第三,ask列表里的命令,就是源码里BARE_SHELL_PREFIXES覆盖的那批。原因很简单:这类前缀一旦配成Bash(bash:*)、Bash(env:*),用户相当于给了"执行任意后续命令"的大权限。它们不会被系统主动建议保存,因为这会大幅削弱整套安全链。但如果用户手动硬配超宽规则,比如Bash(*),那当然仍然能把很多保护意义降到很低。

第四,sandbox配置里的autoAllowBashIfSandboxed是一条很实用的分支:如果 sandbox 已启用,并且 autoAllowBashIfSandboxed 也启用,并且这条命令确实会在 sandbox 中执行,那某些 Bash 调用可以不弹确认,直接因为"会在沙箱里运行"而放行。但它不是无条件压过规则的"超级白名单"。源码里会先检查 full-command 和 subcommand 上的显式 deny / ask 规则,只有这些都没命中时,sandbox auto-allow 才真正生效。这意味着 Claude Code 的真实授权逻辑,并不是"先授权,再决定要不要 sandbox",而是两者会互相影响。

关于沙箱本身,shouldUseSandbox()的逻辑很明确,只要出现下面任一情况就会返回 false:sandbox 没开;上层 SandboxManager 判定当前平台 / 依赖下 sandbox 实际不可用;用户显式传了dangerouslyDisableSandbox;命中了excludedCommands(注意这更像用户便利开关,不是 security boundary)。所以更准确的说法是:沙箱是 Claude Code 的重要执行隔离层,但不是每条命令都会经过。

当它启用时,防线确实很硬,因为这是 OS 级约束,不再只是"逻辑上认为不该执行"。源码里能直接看到的保护点包括:denyWrite Claude 自己的 settings 文件;denyWrite.claude/skills;处理 bare git repo 相关路径(HEAD、objects、refs、hooks、config);网络域名 allow / deny / blockAll。其中对 git 的保护尤其值得一提,这里不是简单"把 .git 全禁写"这么粗暴,而是专门针对 planted bare repo、core.fsmonitor、hook / config 导致的后续执行链做了更细的 deny / scrub 处理。

沙箱支持平台是 macOS、Linux、WSL2+,而且是否真正启用还受依赖和设置影响。这意味着它不是一个"天然存在的安全常量",而是一个需要被确认已启用、已生效、依赖齐全的能力。

4. 验证请求:用 TaoToken 统一 Key 通道跑通端到端测试

配置写好了,怎么验证它真的生效?这里我用 TaoToken 作为统一 Key 通道来接入 API,完成端到端测试。TaoToken 的官网是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点是 https://taotoken.net/api 。

先拿 Key。打开 https://taotoken.net/api-keys ,创建一个新的 API Key,复制出来。然后配置环境变量:

export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"

如果你用的是 Claude Code 的 Anthropic 兼容模式,需要配置 Base URL 和 Key。在~/.claude/settings.json里加上:

{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-你的key" } }

如果你用的是 Codex,配置落在~/.codex/auth.json:

{ "OPENAI_API_KEY": "sk-你的key", "OPENAI_BASE_URL": "https://taotoken.net/api" }

Model ID 根据你实际使用的模型填,比如claude-sonnet-4-20250514或gpt-4o。这三件套——Base URL、Key、Model ID——缺一不可,配错任何一个都会导致 401 或 model not found。

配好之后,先做一个最小验证请求,确认通道是通的:

curl -s https://taotoken.net/api/v1/messages \ -H "x-api-key: $TAOTOKEN_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ -d '{ "model": "claude-sonnet-4-20250514", "max_tokens": 128, "messages": [ {"role": "user", "content": "回复 OK 两个字母即可"} ] }'

如果返回里能看到content数组和正常的文本,说明 Key 通道没问题。这一步很关键,因为后面 BashTool 的测试依赖模型能正常返回工具调用。

接下来测试 BashTool 的安全链。在 Claude Code 里输入一条只读命令,比如:

帮我看看当前目录的 git 状态

Claude Code 会生成git status并尝试执行。因为git status在 allow 列表里,且是只读快速通道,应该直接放行,不弹确认。你可以在终端看到命令执行结果。

再测试一条会被打成too-complex的命令:

帮我执行 cd $(pwd) 然后列出文件

这条命令里的$()直接充当参数本体,AST 会把它打成too-complex,然后走 ask。你应该能看到权限确认弹窗,而不是直接执行。这就是 fail-closed 的效果。

再测试一条危险命令:

帮我执行 rm -rf /

这条会命中 deny 规则,直接被拦,连 ask 都不会弹。如果你在沙箱里测试,还可以验证网络隔离:尝试curl http://example.com,如果沙箱的 blockAll 生效,会看到网络被拒绝的报错。

如果你想在 CI 里跑这套验证,可以把上面的 curl 请求写成一个脚本,配合set -e和退出码检查:

#!/usr/bin/env bash set -euo pipefail RESP=$(curl -s -w "\n%{http_code}" https://taotoken.net/api/v1/messages \ -H "x-api-key: $TAOTOKEN_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ -d '{ "model": "claude-sonnet-4-20250514", "max_tokens": 64, "messages": [{"role": "user", "content": "ping"}] }') HTTP_CODE=$(echo "$RESP" | tail -n1) BODY=$(echo "$RESP" | sed '$d') if [ "$HTTP_CODE" != "200" ]; then echo "请求失败,HTTP $HTTP_CODE" echo "$BODY" exit 1 fi echo "通道验证通过"

这个脚本可以直接放进 CI 的 smoke test 阶段,每次部署前跑一遍,确认 Key 通道和模型可用性。

5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth

配置和验证过程中,最容易踩的坑集中在几个报错上。下面按真实报错逐条对照。

401 Unauthorized。这个最常见,原因通常是 Key 没配对或者 Base URL 写错了。检查三件套:ANTHROPIC_API_KEY或OPENAI_API_KEY是否填的是 TaoToken 的 Key;ANTHROPIC_BASE_URL或OPENAI_BASE_URL是否是https://taotoken.net/api(注意不要多加/v1,具体看客户端要求);Model ID 是否是 TaoToken 支持的模型名。如果 Key 是从 https://taotoken.net/api-keys 复制的,注意不要带多余空格或换行。

local proxy failed。这个报错通常出现在客户端尝试走本地代理但代理没起来的时候。检查你的环境变量里有没有残留的HTTP_PROXY、HTTPS_PROXY、ALL_PROXY。如果有,先 unset 掉再试:

unset HTTP_PROXY HTTPS_PROXY ALL_PROXY

然后确认ANTHROPIC_BASE_URL直接指向https://taotoken.net/api,不要经过任何中间层。

reading choices 报错。这个通常出现在流式响应解析阶段,报错信息里会带reading 'choices'或类似字段。原因一般是客户端期望 OpenAI 格式的响应,但实际拿到的是 Anthropic 格式,或者反过来。检查你用的客户端是 Anthropic 兼容模式还是 OpenAI 兼容模式,然后确认 Base URL 和请求路径匹配。Anthropic 模式走/v1/messages,OpenAI 模式走/v1/chat/completions。如果客户端自动拼接路径,确认它拼出来的完整 URL 是对的。

OAuth 相关报错。如果你用的是 Claude Code 的 OAuth 登录流程,但同时又配了 API Key,可能会冲突。Claude Code 优先走 OAuth 还是 API Key 取决于配置。如果你要用 TaoToken 的 Key 通道,建议在settings.json里显式配置ANTHROPIC_API_KEY和ANTHROPIC_BASE_URL,并且确认没有残留的 OAuth token 文件干扰。如果报错里出现OAuth token expired或invalid_grant,先清理掉旧的 OAuth 凭据再重新配 Key。

CC Switch / Cline MCP / Codex auth.json 配置不全。如果你用 CC Switch 切换配置,或者用 Cline 的 MCP 模式,或者用 Codex,记住三件套必须写全:Base URL、Key、Model ID。以 Codex 的~/.codex/auth.json为例:

{ "OPENAI_API_KEY": "sk-你的key", "OPENAI_BASE_URL": "https://taotoken.net/api", "model": "gpt-4o" }

少任何一个都会导致请求失败。Cline 的 MCP 配置里,Base URL 和 Key 填在 provider 设置里,Model ID 填在模型选择里,三者要对应。

沙箱不生效。如果你配了sandbox.enabled: true但发现命令还是直接执行了,检查几个点:平台是否是 macOS / Linux / WSL2+;shouldUseSandbox()是否因为excludedCommands命中而返回 false;是否显式传了dangerouslyDisableSandbox。另外,sandbox auto-allow 只在显式 deny / ask 规则都没命中时才生效,如果你配了Bash(*)这种超宽 allow,沙箱的保护意义会被大幅削弱。

too-complex 过于频繁。如果你发现很多正常命令都被打成too-complex,检查命令里是否有未加引号的变量展开、glob、或者$()直接充当参数本体。这些结构本身就会触发 fail-closed。解决办法是把命令拆简单,或者把$()放到双引号字符串里,让 AST 能递归抽出内层命令。

排障的时候,建议先跑第 4 节那个 curl 脚本确认通道没问题,再逐步加配置。通道不通的情况下调权限配置是白费功夫。

6. 把安全链用起来:从配置到 CI 的落地路径

拆完这条链,最值得带走的不是某一条 regex,而是三个更底层的工程原则。

第一,先拿到可信语义,再谈授权。这也是为什么 tree-sitter / AST fail-closed 会成为现版本主入口。如果你的 Agent 还在用正则匹配危险命令,第一课不是多写几条规则,而是先把解析这件事做对。你的安全检查看到的和 shell 真正执行的,究竟是不是同一条命令?如果这个问题答不上来,后面的所有规则都建立在流沙上。

第二,把"只读自动化"收得很窄,把"不确定"统一打回确认。这比"先大胆放,再出事了加黑名单"要成熟得多。Claude Code 的 read-only quick path 不是"一个数组 + includes()",而是 COMMAND_ALLOWLIST 为主、READONLY_COMMAND_REGEXES 为补充,再叠加额外危险回调和 git 专项 hardening 的组合系统。每个 flag 都有类型约束,未加引号的变量展开、glob、UNC 路径、路径访问、输出重定向仍然要继续校验。

第三,逻辑校验和执行隔离要并存。仅有规则,没有 sandbox,不够;仅有 sandbox,没有可信 argv 和权限编排,也不够。destructive warning 只是提示层,不是授权层——destructiveCommandWarning.ts文件顶部就写了这是 purely informational,不影响 permission logic 或 auto-approval。它解决的是"让用户和模型在确认时更清楚风险是什么",而不是"独立承担一层阻断式安全决策"。

如果你要在 CI 里落地这套东西,建议的路径是:先用 TaoToken 的 Key 通道把模型调用跑通,确认 Base URL、Key、Model ID 三件套没问题;然后在项目里放一份.claude/settings.json,把 allow / deny / ask 规则和 sandbox 配置写进去;接着在 CI 的 smoke test 阶段跑第 4 节那个 curl 脚本,确认通道可用;最后用几条测试命令验证安全链——只读命令直接放行、too-complex命令弹确认、危险命令被 deny、沙箱网络隔离生效。

还有一个经常被忽略的点:MAX_SUBCOMMANDS_FOR_SECURITY_CHECK = 50。在 legacy splitCommand 路径下,超过这个 fanout,Claude Code 会直接 ask,避免在极端 compound command 上继续展开分析。这不是漏洞,而是典型的工程取舍:宁可多问一次,也不在超长命令上冒误判风险。你在设计自己的 Agent 时,也应该有类似的兜底阈值。

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

如果你现在就想动手试,可以从 https://taotoken.net/api-keys 拿一个 Key,按第 3 节的配置片段把 settings.json 写好,然后用第 4 节的 curl 脚本验证通道。跑通之后,再逐步加权限规则和沙箱配置。遇到 401 或 local proxy failed,回到第 5 节对照排查。需要查接入文档的话,https://taotoken.net/doc 有完整的参数说明;想先验证模型对话是否正常,https://taotoken.net/chat 可以直接试;如果是长期编码或 Agent 场景,https://taotoken.net/coding-plan 有对应的方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询