7 · 权限模型与安全边界
智能体是把不可信的模型输出直接变成系统调用的东西。
这句话值得展开:模型的输出是概率性的、可被诱导的。而智能体会把这个输出直接翻译成「删除这个文件」「执行这条命令」「发送这封邮件」。它把提示词注入攻击从「让 AI 说错话」升级成了「让 AI 执行任意命令」。
做智能体服务端,你迟早要回答这个问题:凭什么让这条 shell 命令跑起来?
两套系统给出了两种完全不同、但都很成熟的答案。
7.1 Claude Code 的做法:十级决策级联
核心函数叫 hasPermissionsToUseToolInner()(意思是「是否有权限使用这个工具·内部实现」)。它是一条严格有序的判定链 —— 从上到下逐条检查,第一个命中的就直接决定结果,后面的不再检查。源代码里连编号注释都写好了。
逐条解释这十步:
| 步骤 | 检查什么 | 命中的结果 |
|---|---|---|
| 0 | 中止信号已经拉了吗 | 直接拒绝(用户已经按了 Ctrl+C) |
| 1a | 整个工具被「拒绝规则」命中 | DENY(拒绝) |
| 1b | 整个工具被「询问规则」命中 | ASK(弹出确认框问用户) 例外:如果这条 Bash 命令能在沙箱里安全执行,跳过这一步继续往下 |
| 1c | 调用工具自己的 checkPermissions() | 不直接出结果,把工具自己的判断拿到手 比如 Bash 工具会在这里检查「用户是否为某个具体子命令配了规则」 |
| ↓ ↓ ↓ 以下四步是「bypass 免疫层」—— 开了跳过权限也照样拦 ↓ ↓ ↓ | ||
| 1d | 工具自己明确说了「拒绝」 | DENY |
| 1e | 这个工具必须有人在场才能完成 | ASK 比如「向用户提问」这个工具,没人在场就没有意义 |
| 1f | 用户显式配了内容级的询问规则 | ASK 比如用户配了 Bash(npm publish:*) —— 意思是「凡是发布 npm 包的命令,都要问我一次」 |
| 1g | 安全检查:碰到敏感路径了 | ASK.git/(版本控制数据)、.claude/(工具自身配置)、.vscode/、shell 启动脚本 |
| ↑ ↑ ↑ 以上四步是「bypass 免疫层」 ↑ ↑ ↑ | ||
| 2a | 用户开了 bypassPermissions 模式 | ALLOW(放行) |
| 2b | 整个工具被「允许规则」命中 | ALLOW |
| 3 | 以上都没命中 | ASK(默认落到人工确认) |
先解释 bypassPermissions 是什么:它对应命令行参数 --dangerously-skip-permissions(危险地跳过权限)。用户开了它,就不会再弹任何确认框,智能体全自动执行所有操作。这个模式存在的理由很实在 —— 反复点「确认」非常烦人,会毁掉自动化的价值。
但注意判定链的顺序:1d、1e、1f、1g 这四步排在 2a 前面。也就是说,即使用户开了「危险地跳过权限」,这四类检查依然会拦截:
- 工具自己明确拒绝的操作
- 需要人类在场才能完成的操作
- 用户自己显式配置过要问的操作(尊重用户更具体的意图)
- 触碰
.git/、.claude/、shell 配置这些敏感路径的操作
「绕过权限」不等于「绕过一切」。存在一个用户无法通过任何配置关掉的最小安全底座。
这是一个非常成熟的产品判断:给用户「关掉烦人确认」的自由,但不给他们「一键自毁」的自由。如果不留这个底座,第一个不小心让智能体把自己的 .git 目录删掉的用户,会永久失去对这个产品的信任。
7.2 自动模式:用模型判断安全性,加三级快速通道
当判定结果落到 ASK(需要问用户)、而用户又开了「自动模式」时,Claude Code 不弹窗,而是再调一次模型,让模型来判断这个动作安不安全。这个专门用来做安全判断的模型调用,源码里叫「分类器」(classifier)。
但分类器不便宜 —— 每个工具调用都要额外发一次 API 请求。所以前面挡了三级快速通道:
(acceptEdits 是一个中间档的权限模式,意思是「文件编辑类操作自动放行,其他操作还是要问」。它比默认模式宽松,但比 bypass 严格。)
快速通道 ① 的实现很巧妙 —— 把权限模式临时替换掉,再问工具一次:
const acceptEditsResult = await tool.checkPermissions(parsedInput, {
...context,
getAppState: () => ({ // 伪造一个"应用状态"读取函数
...context.getAppState(),
toolPermissionContext: { ...state.toolPermissionContext,
mode: 'acceptEdits' as const }, // ← 临时改成宽松模式
}),
})
if (acceptEditsResult.behavior === 'allow') {
// 在宽松模式下都会放行的操作,在自动模式下没必要再问分类器
return { behavior:'allow', ..., decisionReason:{ type:'mode', mode:'auto' } }
}
claude-code/src/utils/permissions/permissions.ts
但有两个工具被明确排除在这条快速通道之外,源码注释说明了原因:
「REPL code can contain VM escapes between inner tool calls; the classifier must see the glue JavaScript, not just the inner tool calls.」
译:REPL 工具的代码可能在内部工具调用之间夹带虚拟机逃逸;分类器必须看到那些胶水 JavaScript 代码,而不只是看内部的工具调用。
解释一下这句话:REPL 工具允许模型写一段 JavaScript 代码,在这段代码里编排多个内部工具调用(比如「读这个文件,如果包含某个关键词就改它」)。
如果只检查那些内部工具调用,每一个看起来都很安全(读文件、写文件)。但把它们粘在一起的那段 JavaScript 代码本身,可能是一次虚拟机逃逸尝试 —— 也就是试图突破沙箱去访问不该访问的东西。
这是「组合安全 ≠ 单元安全」的一个绝佳例子。A 安全、B 安全,不代表「先 A 再 B,中间夹一段自定义逻辑」也安全。
分类器的输入需要收敛:toAutoClassifierInput
回顾第 5.1 节,工具接口里有一个专门为分类器服务的方法:
/**
* Returns a compact representation of this tool use for the auto-mode
* security classifier. Examples: `ls -la` for Bash, `/tmp/x: new content`
* for Edit. Return '' to skip this tool in the classifier transcript
* (e.g. tools with no security relevance).
*/
toAutoClassifierInput(input: z.infer<Input>): unknown
译:返回这次工具调用的一个紧凑表示,供自动模式的安全分类器使用。例子:Bash 工具返回 ls -la,Edit 工具返回 /tmp/x: 新内容。返回空字符串则表示「在分类器的对话记录里跳过这个工具」(适用于没有安全含义的工具)。
为什么需要这个?因为分类器要看整个对话记录才能判断意图(一条 rm -rf build/ 命令,在「用户要求清理构建产物」的语境下安全,在别的语境下可能不安全)。但如果把每个工具的完整输入都塞进去,分类器的上下文自己就爆了。
所以每个工具自己提供一个只保留安全语义的压缩表示:Bash 给命令行文本,Edit 给「路径 + 新内容」,而没有安全含义的工具(比如更新待办列表)直接返回空串,根本不进分类器的视野。
连续拒绝跟踪
还有一个 denialTracking(拒绝追踪)机制:记录连续被拒绝的次数,达到阈值就不再信任分类器,回退到人工确认。任何一次成功放行都会调 recordSuccess() 把计数清零。
这个机制防的是一种僵局:分类器因为某种误判一直拒绝,模型不明白为什么,就一直换着写法重试 —— 双方都在烧钱,但永远推进不了。连续拒绝达到阈值时把决定权交回给人,是唯一能打破僵局的办法。
7.3 Hermes 的做法:正则红线 + 对抗性解析
Hermes 没有用模型做安全判断,它走的是确定性的模式匹配路线 —— 用正则表达式(回顾第 1.9 节)去识别危险命令。但它把「对抗性输入处理」做到了近乎偏执的程度。approval.py 这个文件有 5,802 行。
12 条无条件红线
HARDLINE_PATTERNS = [ # hardline = 硬红线
(_RM_FLAG_PREFIX + _hardline_rm_path(r'/(?:(?:\.\.?)?/)*(?:\.\.?)?\**|/ \*'),
"recursive delete of root filesystem"),
# 递归删除根文件系统
(_RM_FLAG_PREFIX + _hardline_rm_path(_HARDLINE_SYSTEM_DIRS),
"recursive delete of system directory"),
# 递归删除系统目录
(_RM_FLAG_PREFIX + _hardline_rm_path(r'(?:~|\$\{?HOME\}?)(?:/?|/\*)?'),
"recursive delete of home directory"),
# 递归删除用户主目录
(_CMDPOS + r'mkfs(\.[a-z0-9]+)?\b', "format filesystem (mkfs)"),
# 格式化文件系统
(_CMDPOS + r'dd\b[^\n]*\bof=/dev/(sd|nvme|hd|mmcblk|vd|xvd)[a-z0-9]*',
"dd to raw block device"),
# 直接往裸磁盘设备写数据
(r'>\s*/dev/(sd|nvme|hd|mmcblk|vd|xvd)[a-z0-9]*\b',
"redirect to raw block device"),
# 重定向输出到裸磁盘设备
(r':\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:', "fork bomb"),
# 分叉炸弹:无限自我复制进程,直接卡死机器
(_CMDPOS + r'kill\s+(-[^\s]+\s+)*-1\b', "kill all processes"),
# 杀死系统所有进程
(_CMDPOS + r'(shutdown|reboot|halt|poweroff)\b', "system shutdown/reboot"),
# 关机 / 重启
...
]
hermes-agent/tools/approval.py
「无条件」的意思是:用户配了什么都没用,这些命令永远不会被执行。这和 Claude Code 的「bypass 免疫层」是同一个思想的不同实现 —— 都是在给用户自由的同时保留一个不可协商的底座。
真正的难点不在写正则,而在避免误伤
最朴素的实现是 if "rm -rf /" in command: 拦截。这个实现造成了一个真实的事故 —— 源码注释记录了它:
「…so the rule fires only when rm is an actual command word — not when the literal string "rm -rf /" appears as DATA inside another command's argument, e.g. gh pr create --title "block rm -rf / spellings" or git commit -m "…rm -rf /…". Those tripped the unconditional floor and could not run at all before the anchor.」
译:……所以这条规则只在 rm 确实处于「命令词」位置时才触发 —— 而不是当字符串 "rm -rf /" 作为数据出现在另一个命令的参数里时也触发,比如 gh pr create --title "block rm -rf / spellings"(创建一个标题里含这段文字的合并请求)或者 git commit -m "…rm -rf /…"(提交一条含这段文字的说明)。在加上位置锚点之前,这些命令都会撞上无条件红线,完全无法执行。
也就是说:你没法提交一条说明文字里含 "rm -rf /" 的代码提交,因为安全规则把它当成真的删库命令拦了。
解法是一个叫 _CMDPOS 的位置锚点(CMDPOS = command position,命令位置)。它只在下面这几个位置匹配:
- 行首 ——
rm -rf / - 命令分隔符之后 ——
cd /tmp; rm -rf /、make && rm -rf /、a || rm -rf /、x | rm -rf / - 子 shell 开启符之后 ——
$(rm -rf /)或反引号包裹 - 包装命令之后 ——
sudo rm -rf /、env X=1 rm -rf /、exec rm -rf /
而在 --title "block rm -rf / spellings" 这个例子里,rm 前面是一个空格和引号,不属于上述任何一种位置 —— 所以不匹配,命令正常执行。
引号遮蔽:但要给「真的会执行的部分」留一个后门
有两条红线规则没有命令名可以锚定:重定向符号 > /dev/sda 和分叉炸弹的函数定义 —— 它们在命令行的任意位置都有效。
对这两条,Hermes 用的是引号内容遮蔽:
def _mask_quoted_prose(command: str) -> str:
"""Blank out quoted string CONTENT for positionless hardline matching.
…text inside single or double quotes is data the shell passes as an
argument, so `echo "cat f > /dev/sda"` must not trip the unconditional
floor. Structure is preserved: the quote characters themselves stay,
and inside double quotes `$(...)` command substitutions and backtick
spans are kept RAW because the shell really executes them
(`echo "$(cat f > /dev/sda)"` remains a true positive).
"""
译:为那些无位置的红线规则,把引号里的内容清空。……单引号或双引号里的文字是 shell 作为参数传递的数据,所以 echo "cat f > /dev/sda"(只是打印这段文字)不该撞上无条件红线。结构会被保留:引号字符本身留着,而且双引号里的 $(...) 命令替换和反引号片段保持原样不遮蔽,因为 shell 真的会执行它们(所以 echo "$(cat f > /dev/sda)" 仍然是真正的危险命令)。
这一段体现的是对 shell 语义的精确建模,不是简单的字符串处理。它区分了三种情况:
| 命令 | 该拦吗 | 为什么 |
|---|---|---|
cat f > /dev/sda | 拦 | 真的在往磁盘设备写数据 |
echo "cat f > /dev/sda" | 不拦 | 引号里是数据,只是打印一段文字 |
echo "$(cat f > /dev/sda)" | 拦 | 虽然在引号里,但 $(...) 会被 shell 真正执行 |
而且引号不能成为绕过手段
_SHELL_CARRIER_NAMES = frozenset({ # carrier = 载体
"eval", "sh", "bash", "zsh", "ksh", "dash", "source", ".",
})
def _contains_shell_carrier(command: str) -> bool:
for _, _, word in _iter_shell_command_word_spans(command):
name = os.path.basename(
_deobfuscate_shell_word_for_detection(word) # ← 还带反混淆处理
).lower()
if name in _SHELL_CARRIER_NAMES:
return True
逻辑是:如果命令里出现了 sh -c "..."、bash -c "..."、eval "..." 这类「把引号内容交给另一个 shell 去执行」的命令,那么引号里的东西就是代码而不是散文 —— 遮蔽规则整个失效,必须扫描原始字符串。
源码注释用一句话总结了这个原则:「quoting is not a bypass」(加引号不是绕过手段)。
注意里面还有一个 _deobfuscate_shell_word_for_detection(为检测目的对 shell 词做反混淆)—— 攻击者可能把 bash 写成 b''ash 或 ba\sh,shell 解析后仍然是 bash。这个函数负责把这类混淆还原。
路径归一化的细致程度
「哪些写法其实等于根目录」这个看似简单的问题,Hermes 给出的答案是:
会被判定为根目录(要拦):
/ · // · /. · /./ · /.. · /../.. · /* · //* · / *(shell 会把它看成两个参数:/ 和通配符 *)· 以及带引号的 "/"、"$HOME"、${HOME}
不会被判定为根目录(不拦):
/tmp · /home · /.ssh · /.config · /...(一个真的叫「...」的目录)
判定规则:每两个斜杠之间的片段必须恰好是 . 或 ..。更长的点串(比如 ...)或者任何真实名字,都算字面目录而不是根目录。
连正则表达式的预编译都有理由
# Building these at module load eliminates the ~2.6 ms cold-cache
# re.compile fan-out on the first terminal() call per process
# (12 HARDLINE + 47 DANGEROUS patterns, each potentially evicted from
# Python's 512-entry ``re._cache`` by unrelated regex work elsewhere).
HARDLINE_PATTERNS_COMPILED = [...]
译:在模块加载时就把这些正则编译好,可以消除每个进程第一次调用 terminal() 时约 2.6 毫秒的冷缓存编译开销(12 条硬红线 + 47 条危险模式,每一条都可能因为程序其他地方无关的正则操作,而被 Python 那个只有 512 项的正则缓存挤出去)。
也就是说:如果不预编译,59 条正则表达式在第一次使用时要现场编译,多花 2.6 毫秒。更麻烦的是,Python 的正则缓存只有 512 项,程序其他地方一忙就会把这些正则挤出缓存,导致反复重新编译。
7.4 Hermes 的第二道防线:执行环境隔离
正则表达式只是软防御 —— 它拦的是「一眼看去就是灾难」的命令。Hermes 真正的硬边界是可插拔的执行环境:
tools/environments/
├── local.py 直接在用户本机跑 ← 默认,无沙箱 ⚠️
├── docker.py 在 Docker 容器里跑(容器级隔离)
├── modal.py 在 Modal 云端沙箱里跑
├── managed_modal.py 托管版的 Modal
├── daytona.py 在远程开发环境里跑
├── vercel_sandbox.py 在 Vercel 沙箱里跑
├── singularity.py 在 Singularity 容器里跑(高性能计算集群常用)
├── ssh.py 通过 SSH 在另一台主机上跑
└── base.py 统一的抽象基类
2026 年 4 月,一次第三方安全审计检查了 Hermes 约 36.4 万行代码。审计结果是:没有发现恶意代码、后门或隐藏的数据上报。但同时报告了 4 个「严重」(critical)级别、9 个「高」(high)级别的架构问题。
头号问题是:在默认的 local(本机)后端下,terminal 工具把命令直接交给系统 shell 执行,没有沙箱、没有白名单。换句话说,默认安装等于给模型一个真实的、完整权限的终端。
这说明了一件很重要的事:在纵深防御体系里,正则表达式是最外面、也是最薄的一层。它能拦住「rm -rf /」这种一眼就看出问题的命令,但它拦不住一条精心构造的、语法上无害而语义上有害的命令。真正的边界是隔离,不是模式匹配。
相比之下,Claude Code 默认走操作系统级沙箱(macOS 上用 sandbox-exec,也叫 seatbelt 机制),并且有一个专门的模块 readOnlyCommandValidation.ts(66.7 KB)做「这条命令是否只读」的判定 —— 判定为只读的命令可以自动放行,不用问用户。
7.5 一个常被忽略的攻击面:错误消息回灌
这一点在第 5.6 节已经提过,这里再强调一次,因为它太容易被漏掉。
Hermes 在 model_tools.py 里对工具的错误信息做了净化处理:
_TOOL_ERROR_ROLE_TAG_RE = re.compile(...) # 剥离伪造的角色标签
_TOOL_ERROR_FENCE_OPEN_RE = re.compile(r'^\s*```(?:json|xml|html|markdown)?\s*')
_TOOL_ERROR_FENCE_CLOSE_RE= re.compile(r'\s*```\s*$') # 剥离 Markdown 代码围栏
_TOOL_ERROR_CDATA_RE = re.compile(r'<!\[CDATA\[.*?\]\]>', re.DOTALL)
def _sanitize_tool_error(error_msg: str) -> str: ...
攻击路径是这样的:
这类攻击面的共同特征是:它们走的是异常路径,所以正常的功能测试完全覆盖不到。你的测试会验证「工具成功时行为正确」,但很少验证「工具失败时的报错信息里有什么」。
值得在自己的项目里专门排查一遍:列出所有会把外部数据回灌进模型上下文的路径。工具执行结果、错误消息、日志内容、异常堆栈 —— 每一条都是潜在的注入入口,每一条都需要净化。
7.6 两家对照与取舍
| 维度 | Claude Code | Hermes |
|---|---|---|
| 核心机制 | 10 级规则级联 + 模型分类器 | 正则表达式红线 + 执行环境隔离 |
| 决策成本 | 分类器要花钱(每次一个额外 API 请求),靠三级快速通道挡掉大部分 | 纯 CPU 计算,预编译正则,成本接近于零 |
| 语义理解能力 | 强。分类器看完整对话记录,能理解「这条命令在当前语境下是否合理」 | 弱。只看单条命令的语法形态,不理解意图 |
| 对抗性输入的 处理能力 | 依赖分类器本身的鲁棒性 | 极强。命令位置锚定、引号遮蔽、反混淆、路径归一化,每一项都是被真实误报逼出来的 |
| 不可绕过的底座 | 判定链里 1d 到 1g 四步的 bypass 免疫层 | HARDLINE_PATTERNS 12 条无条件红线 |
| 默认隔离级别 | 操作系统级沙箱(默认开启) | ⚠️ local 后端默认无沙箱 |
| 误报的处理 | 连续拒绝追踪,达到阈值回退人工确认 | 命令位置锚定,避免把数据当命令 |
四层,从外到内讲:
- 工具面收窄(最有效的一层)。不同信任边界给不同的工具集 —— Hermes 的 webhook 工具集只有 4 个只读工具。危险操作最好的防护,是那个工具根本不在模型能看到的清单里,而不是在提示词里写「请不要执行危险命令」。模型无法调用一个它不知道存在的工具。
- 规则级联 + 不可绕过的底座。允许用户关掉烦人的确认(否则自动化就没价值了),但保留一层他们关不掉的检查:敏感路径、需要人在场的操作、用户自己显式配置过的规则。Claude Code 的判定链把这四步排在 bypass 检查之前,顺序就是设计。
- 语义判定。规则匹配不了意图,需要模型看完整上下文来判断。但分类器很贵,前面必须挡快速通道(已知安全的白名单、更宽松模式下也允许的操作)。
- 执行隔离。前三层都是软的,真正的边界是容器 / 沙箱 / 独立用户账号。Hermes 审计报告的头号严重问题就是「默认无沙箱」。
如果想进一步拉开差距,讲一个具体的坑:
「命令」和「数据」必须区分。朴素的 if "rm -rf /" in cmd 会导致 git commit -m "fix: block rm -rf / spellings" 被拦 —— 这是 Hermes 真实修过的 bug。正确做法是只在命令位置匹配(行首 / 分隔符后 / $( 后 / sudo 包装后),并且对引号内容做遮蔽。
但要注意两个反例:双引号里的 $(...) 不能遮蔽,因为 shell 真的会执行它;遇到 sh -c / eval 这类 shell 载体时遮蔽整个失效,因为那里引号里的内容就是代码。
最后补一句判断力:误报率过高的安全措施等于没有安全措施 —— 因为用户会直接把它整个关掉。