拆解 Claude Code BashTool 安全链路:AST 解析 + 沙箱隔离如何防住 Shell 命令搞事

发布时间:2026/10/18 11:29:08
拆解 Claude Code BashTool 安全链路:AST 解析 + 沙箱隔离如何防住 Shell 命令搞事 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 expansioncurl 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_expressioncase_statement / function_definitionANSI-C / translated string不安全 heredoc以及任何未显式 allowlist 的 node typeparser aborted、控制字符、Unicode 空白、zshcmd这类 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 等危险 builtinzmodload、sysopen、sysread、ztcp、zsocket 等 zsh 专有 builtintimeout/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()的逻辑很明确只要出现下面任一情况就会返回 falsesandbox 没开上层 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 端点是 https://taotoken.net/api 。先拿 Key。打开 https://taotoken.net/api-keys 创建一个新的 API Key复制出来。然后配置环境变量export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://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 的 KeyANTHROPIC_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/messagesOpenAI 模式走/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 / WSL2shouldUseSandbox()是否因为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 路径下超过这个 fanoutClaude 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 有对应的方案。