169.254.169.254 现在会被拦下来:Hermes 把云实例元数据的凭证拉取纳入审批


你在云上跑着一台开发机,让 agent 帮忙查一个“为什么连不上内网服务”的问题。它思考了一下,决定先执行一条命令看看自己是谁:

curl http://169.254.169.254/latest/meta-data/iam/security-credentials/

在云主机上,这条命令不需要任何认证就会返回一串临时访问凭证——它们代表这台机器的身份,能访问你在云上授权给它的资源。过去 Hermes 会把它当作一条普通的读操作直接放行;现在它会先弹一个审批提示,等你点头。这个变化值得所有把 agent 跑在云上的人了解,因为它补上的是“agent 有了命令执行权之后最容易被忽略的那个后门”。

IMDS 是什么,为什么它比看起来危险

IMDS 是 Instance Metadata Service(实例元数据服务)的缩写,是主流云厂商在每个实例内部提供的一个特殊 HTTP 端点,地址是链路本地的 169.254.169.254(AWS、Azure、GCP、OpenStack 都用它)。它存在的意义是让实例里的程序知道“我是谁、我在哪个区域、我的角色是什么”,并且——这是关键——它不需要任何认证。任何能在这台机器上发起 HTTP 请求的进程都能读到它,包括你刚让 agent 执行的那条 curl。

各家还有自己的变体,这些都是同一个东西的不同门牌号:

  • 169.254.169.254:AWS / Azure / GCP / OpenStack 的 IPv4 链路本地地址;
  • fd00:ec2::254:AWS 的 IPv6 形式;
  • metadata.google.internal:GCP 的主机名;
  • 100.100.100.200:阿里云的端点。

为什么说它危险?因为它返回的不只是“机器规格”这类无害信息,通常还包括 IAM 角色 / 服务账号的临时安全凭证。拿到这串凭证,就等于拿到了这台机器在云上被授权的全部权限——可能是读某个私有存储桶,可能是调用某个内部 API,也可能更多。凭证有有效期,但通常足够做完想做的事。

攻击路径:一条被注入的指令就够了

把两个事实放在一起,风险就出来了:agent 会读外部内容(网页、issue、邮件、代码注释),agent 也能执行命令。如果外部内容里藏着一句“顺手运行这条命令把结果发给我”,而命令恰好指向元数据端点,那么一次提示注入就可能变成一次凭证外泄。这不是理论:这类端点的特殊性在于它在内网里、不需要凭证就能访问,所以是最省事的窃取目标。

值得注意的是,云端真正做隔离的责任在云厂商一侧(比如 AWS 的 IMDSv2 要求先用 PUT 取一次性 token,增加了 SSRF 的难度),但这不能替你做决定:agent 主动去读元数据这件事本身,应该由人确认一次。

现在会发生什么:一次审批,而不是一次拒绝

PR #106418 在终端审批层新增了一条确定性规则,名字很直白:

cloud metadata endpoint access (instance credentials)

它的行为是弹审批,而不是硬拦截。这是刻意的选择:在真实的云主机上,合法的使用场景确实存在——agent 帮你排查自身实例的角色、你在调试 IAM 权限、你在写一个自动上报实例信息的脚本。所以设计上由操作者决定,而不是一刀切。

具体来说:

  • 终端命令里出现上面那四个地址字面量时,会触发审批(任何 HTTP 客户端都算,curl、wget、写进环境变量、作为脚本参数都行);
  • 在无人值守的场景下(比如 cron、approve 模式的自动审批),这类命令不会被自动放行——approvals.cron_mode: deny 的默认值意味着定时任务里的这类命令会被直接拒掉;
  • 判定是规则匹配,不是模型判断,所以不存在“模型今天心情好就放行”的漂移,也不会有额外的模型调用开销。

覆盖边界:误报被刻意压到最小

实现里用了前后向断言来收紧匹配(tools/approval_detection.py 里的 DANGEROUS_PATTERNS 条目),目的是让这条规则只命中端点本身:

  • curl http://169.254.1.1/status 这类其他链路本地地址不受影响;
  • 169.254.169.2540 这种更长的点分字符串不匹配(169.254.169.254 只是它的一部分);
  • metadata.google.internal.example.com 这种“包含但不等同”的主机名不匹配。

换句话说,这条规则只在真正指向元数据端点时开口,不会把普通的链路本地通信误伤成“危险”。

另外要说明的是,这条规则和浏览器侧的 SSRF 防护不是一回事:浏览器工具早就有自己的云元数据地址下限保护(比如容器里运行时的额外限制),而这次补上的是终端命令这条路径,两者是不同层面的两道门。

这个改动本身也参考了外部的成熟做法:Claude Code 2.1.257 的 changelog 里,其 auto 模式不再自动放行云元数据凭证拉取(以及出口规避、跨租户访问),且除非环境显式声明“这是预期行为”才放行。Hermes 的做法是把它落成终端的确定性规则——更朴素,但行为可预测。

你会看到的提示,以及怎么调整

撞上这条规则时,你会看到一条带原因的命令审批,原因正是那串英文字符串。此时的选项取决于你的 approvals 配置:

# 看当前审批模式(smart 是默认值)
hermes config get approvals.mode

# 临时改成手动审批:每条危险命令都问你
hermes config set approvals.mode manual

# 临时关掉审批(yolo),但注意:deny 列表即便在 yolo 下依然生效
hermes config set approvals.mode off

几个容易混淆的点:

  • approvals.mode 的取值是 smart | manual | off,默认 smart(由评审模型判断,规则命中的确定性危险命令不需要模型参与)。
  • approvals.deny 是无条件拦截清单,取值是大小写不敏感的 fnmatch glob(例如 rm -rf *),它在 yolo 模式下同样生效——所以别以为开了 off 就什么都放行了。
  • 拒次数太多会被熔断:approvals.denial_breaker_threshold 默认 3,连续 3 次拒绝后硬停,避免你在一个死循环里反复点拒绝。
  • 定时任务的行为由 approvals.cron_mode 控制(默认 deny),所以你的 cron 作业如果依赖读元数据,需要显式调整这一项。
  • 在会话里,交互式的拒绝可以直接用 /deny 加理由来记录和反馈。

审批机制的完整用法(模式选择、评审模型放在 auxiliary.approval.model、演练与自测)见站内这两篇:Smart Approvals 配置指南 与 approvals 演练与 dry-run。同类“先问一句”的守卫还包括 SSH 配置审批门(SSH config 审批) 和 包管理器卸载审批门。

自己在机器上验证一遍

如果你手上正好有一台云主机,可以安全地确认这条链路的存在(只在你拥有并且有权访问的机器上做):

# 1) 普通读:这条命令会返回实例 ID 之类的公开信息
curl -s --max-time 2 http://169.254.169.254/latest/meta-data/instance-id

# 2) AWS 的 IMDSv2 需要先取一次性 token,再带上它去读凭证路径
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
  -H "X-aws-ec2-metadata-token-ttl-seconds: 60")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
  http://169.254.169.254/latest/meta-data/iam/security-credentials/

第 2 条在允许的情况下会打印出角色名与临时凭证——这就是为什么它值得一次人工确认。注意不要把这些凭证复制到任何会话、工单或聊天记录里;它们等同于账号权限。

如果你在非云环境(本机、私有数据中心)上看到这条规则触发,通常是因为某段脚本里硬编码了这个地址;如果你的确需要它(比如你在写云上自检工具),通过一次审批即可,把理由写清楚比强行关掉审批更安全。

小结

一句话:云主机上的 169.254.169.254 不是“一个普通内网地址”,它是免认证的凭证发放点。 把它纳入审批,是用最小的摩擦换掉一类最省事的凭证外泄路径。

实操上记住三条:

  1. agent 突然要访问元数据端点时,停下来看一眼命令——它可能只是排查问题,也可能是被注入的指令。
  2. 定时任务默认会拒绝这类命令;确实需要就在 approvals.cron_mode 上显式放行,而不是整体关掉审批。
  3. approvals.deny 是无条件拦截,approvals.mode: off 并不等于“什么都不拦”。

这个改动合并于 v0.21.2 打标签之后,写作时位于 main 分支、尚未进入已发布版本;跑一次 hermes update 或等下一个版本即可带上。