id: ADR-014 title: 插件实例只能通过由宿主持有的唯一出口授权机制访问网络 date: 2026-08-20 status: 提议中 relates-to:
- ADR-006
- ADR-009
- https://github.com/zeroclaw-labs/zeroclaw/issues/9395
- https://github.com/zeroclaw-labs/zeroclaw/issues/8850
- https://github.com/zeroclaw-labs/zeroclaw/issues/8398
- https://github.com/zeroclaw-labs/zeroclaw/pull/9580
- https://github.com/zeroclaw-labs/zeroclaw/pull/9137
- https://github.com/zeroclaw-labs/zeroclaw/pull/9582
- https://github.com/zeroclaw-labs/zeroclaw/pull/9584
- https://github.com/zeroclaw-labs/zeroclaw/pull/11098
- https://github.com/zeroclaw-labs/zeroclaw/issues/10996
- https://github.com/zeroclaw-labs/zeroclaw/pull/9126
- crates/zeroclaw-infra/src/net_guard.rs
- crates/zeroclaw-plugins/src/egress.rs
- crates/zeroclaw-plugins/src/component.rs
- crates/zeroclaw-config/src/schema.rs
- crates/zeroclaw-tools/src/helpers/domain_guard.rs
ADR-014:插件实例只能通过唯一的宿主自有出口授权访问网络
上下文
在 #9582 中的强制执行机制发布之前,当插件清单请求 http_client 权限时,ZeroClaw 会将 wasi:http 导入链接到插件存储中。该存储安装了 wasmtime 的默认请求钩子(crates/zeroclaw-plugins/src/component.rs),因此 ZeroClaw 一侧无法决定访客可以连接到哪里:没有目标地址白名单、没有地址类别防护,也没有面向操作人员的配置。清单权限只是请求,宿主会原样授予每项请求(crates/zeroclaw-plugins/src/instance.rs),而 plugins.security.signature_mode 默认为 disabled。因此,未签名的组件可以声明 http_client、获得该权限,并成为通往回环地址服务、本地网关或云元数据地址的无过滤跳板。缺陷 #9395 记录了这一历史后果;现已发布的强制执行机制会应用由宿主掌控的目标策略。
宿主端的对应实现已经是失败即关闭(fail closed)。http_request 工具在 allowed_domains 列表为空时会拒绝每一次调用,并通过 crates/zeroclaw-tools/src/helpers/domain_guard.rs 和 crates/zeroclaw-infra/src/net_guard.rs 校验目标地址及其所有解析后的地址,检查是否落在元数据、回环和私有地址范围内。同一进程中的两条出站路径却提供了相反的安全保证。
随着 ADR-006 将运行时插件定为可选通道的目标,这一边界面临的压力越来越大,因为每个消息通道都是出站(egress)消费者。目前 wit/v0/inbound.wit 明确说明,通道插件在无网络、无套接字的条件下运行;官方插件仓库中also 包含一份位于 wit/unstable/sockets.wit 的套接字接口草案,用于宿主尚未提供的传输方式。无论下一个到来的是哪种传输,都需要一个现成的答案来回答“此实例是否可以访问该地址“,而不是再引入第三种答案。
已交付部分功能。#9580 将内置 HTTP 出站流量迁移到共享网络防护层,#9137 新增了插件侧出站策略基础能力,#9582 在 wasi:http 边界强制执行该策略,#9126 新增了类型化实例配置。运营者授权流程已在 #11098 中落地,覆盖安装和列表操作,该 PR 取代了 #9584;通道绑定则通过 zeroclaw plugin bind 实现,对应 #10996。本记录阐明这些切片所实现并完成的决策。RFC #8398 已作为综合性/已取代的 RFC 关闭;本 ADR 记录了在该拆分后保留下来的、聚焦于网络出站的决策:网络权限对应 Q1,用户扩展的目标地址授权对应 Q4。
替代方案包括:将清单中的声明本身视为授权;为所有已安装插件保留一份全局允许列表;或为每种传输方式设置独立的目标策略。以清单为授权,会保留未签名软件包的 #9395 自我授权路径,违背默认关闭原则,并使清单成为实时权限的第二个事实来源。全局允许列表则无法实现按实例隔离,因为任何已安装插件都可能访问其他插件所需的每个主机。按传输方式设置策略,则会让同一个问题出现三个开关,容易导致策略漂移,而目标决策本身与传输方式无关。
决策
使用统一的出口权限机制,共享策略框架,默认拒绝
所有插件的出站网络访问(当前为 wasi:http,以及未来的任何 socket、WebSocket 或 TLS 配置文件导入)都由同一个宿主侧出口授权机构统一管理。目标匹配、地址分类、NAT64 转换以及解析后的判定结果,都位于 zeroclaw-infra::net_guard 中,内置工具同样使用该模块。crates/zeroclaw-plugins/src/egress.rs 中的实例级服务负责调用这些基础能力,不重新实现其中任何一项,且 zeroclaw-plugins 为此不依赖 zeroclaw-tools。插件与内置工具对某个目标是否可达,必须不会出现判断不一致的情况。
没有获准访问目标的插件实例没有任何网络访问能力。不存在任何兼容模式可让仅凭 http_client 即可获得不受限的 HTTP 访问权限。权限授予的是接口范围,而运营方的列表授予的是访问目标。
即使链接器只向已准入的实例暴露其被授予的导入,服务仍会在操作边界处重新检查传输层所需的权限。这种重复检查是有意为之的纵深防御:传输适配器不得能够将已链接但未被授权的导入转化为网络可达能力。
在 wasi:http 边界强制执行,并使用固定的发送路径
执行策略位于传输边界。一个存放在每个插件存储中的 ZeroClaw hooks 实现会替换 wasmtime 的默认 hooks,其 request hook 会对每个由客户机发起的请求评估策略。发送路径由宿主拥有并固定:先解析一次目标地址,通过共享的守卫验证解析出的地址,然后只连接到这些确切的地址,并使用主机名进行 SNI 和证书验证。由于不会进行第二次解析,DNS 应答无法在检查与连接之间改变其分类。ResolvedDestination 的存在使这一特性成为结构性保证:它保存已验证的地址,并且不提供回到重新查询的途径。
宿主从不代表访客跟随重定向。访客若选择继续跟随重定向,则需发起新的请求,且每一跳都会独立经过完整的策略检查。
当请求被拒绝时,系统向访客返回一条隐去细节的错误信息,该信息仅指明策略名称而不暴露宿主机内部细节,同时在宿主机侧输出一条结构化日志事件,将该次尝试准确归属到具体实例。
该接缝已在 #9582 中为 wasi:http 发布;剩余的门禁涵盖交叉场景、测试夹具覆盖率以及集群灰度发布。
让清单声明,并由运维人员的配置授权
一个插件清单可以在 [egress] 小节中声明包所需的目标地址:精确主机名或显式的后缀模式。该声明属于签名覆盖的请求,是随软件包一同分发的意图声明。在请求时,它永远不会被当作授权来查询。
授权信息保存在运维人员的规范配置中,作为实例 [[plugins.entries]] 行上的明文字段:目标地址使用 egress_hosts,地址类别豁免使用 egress_allow_private。这两个字段与 #[secret] 的 config 映射是并列关系,绝不放在该映射内部,因为运维人员需要审计这份允许列表,它必须保持在他们审计的文件中可以直接读取。
实例的有效权限范围是其清单所请求内容与运维人员所授予内容的交集。仅有声明本身不会授予任何权限,运维人员的授权也不能超出软件包声明所需的范围。有两条授权路径是一等公民:
- 从下方授予仪式中的声明初始化。
- 由运营者直接编写,这是唯一可行的途径,适用于目标地址本身即实例配置的插件,例如自托管的 Gitea、Matrix 主服务器或局域网 Nextcloud。作者无法预知主机地址,因此由配置该插件的运营者授予访问权限。
策略会在每次请求时从规范配置中读取,而不是快照到实例状态中,因此运维人员的修改会在下一次连接时生效,而无需等到下一次重启。ADR-012 仍处于提议(proposed)状态,描述了相关的按代(generation)作用域的应用模式;此处它仅作为上下文参考,并不具有决定性效力。
操作员字段、按请求读取以及清单中的 [egress] 声明已合并。交集逻辑尚未实现,因此在此版本中仅由操作员的授权决定;门禁 G1 涵盖此项。
确保目标语法中不包含“全部允许”形式
条目可以是精确主机名,或明确的 *.suffix 模式。裸域名绝不隐含其子域名,后缀模式绝不匹配域名的顶点(apex)本身。没有任何条目即表示“任意位置”。校验会拒绝单独的 *;未经校验的 * 只会匹配名为字面 * 的主机,因此即使列表绕过了校验,也会默认拒绝(fail closed)。IP 字面量主机只能被完全相等的条目匹配,绝不会被后缀模式匹配,因为地址没有子域名结构;若将其视为点分名称,则会让 *.0.0.1 匹配 127.0.0.1。空列表不匹配任何内容。端口不属于条目的一部分:授权某个主机即授权其所有端口。
保持地址类在白名单之上被阻止
云元数据地址在两种访问模式下都会被无条件拒绝,因此operator为私有目标开启的选项永远不会重新放开这些地址。回环、链路本地和私有网段的目标即使匹配了某个模式也会被拒绝,除非该条目在egress_allow_private中列出了该主机。此例外仅放宽地址类别:列在其中但未出现在egress_hosts中的主机仍会被拒绝;它与内置工具已使用的allowed_private_hosts语义保持一致,使两个使用方共用同一套术语。
An answer set that spans both trust zones is rejected rather than collapsed to one of them, even when private access is authorized. Otherwise resolver ordering or connection fallback silently decides which zone the connection lands in.
Wait, I need to correct that: the input is English and must be translated.
答案集如果同时跨越两个信任区域,则会被拒绝,而不是折叠到其中一个区域,即使已授权私有访问也是如此。否则,解析器排序或连接回退会悄无声息地决定连接最终落入哪个区域。
要求运维人员声明 NAT64 前缀,并对格式错误的列表采用失败即关闭(fail closed)策略
NAT64 转换器会将配置前缀内的任何 IPv6 目标地址投递到其中嵌入的 IPv4 地址。前缀由部署方自行选择,地址本身无法体现前缀,因此控制主机名 DNS 应答的攻击者可以返回一个看似全局可路由的 IPv6 地址,而本地转换器会将其投递到 10.0.0.1 或 169.254.169.254。security.nat64_prefixes 用于让运维人员声明本网络实际运行的转换前缀。条目格式为 <ipv6>/<length>,前缀长度须采用 RFC 6052 第 2.2 节定义的长度,共六种,且前缀之外的位均不得置位。
以下是两条规则,且两条都比显而易见的实现更严格。第一,只要有一个条目无效,就整体拒绝整个列表,而不是只保留其中格式正确的子集。原因在于,若列表被解析为“没有前缀”,它看起来就与未部署任何转换器的部署完全相同,结果会在没有任何提示的情况下禁用网络特定的分类。第二,声明的前缀可能相互重叠,因此同一地址可能在不同前缀下解码为不同的 IPv4 目标地址。这些目标地址都是可达的,所以只有当某地址匹配的每一条已声明转换都落在可接受的地址上时,该地址才会被接受,而不是只要其中某一条恰好可接受即可。
让授权成为一个明确的确认流程
zeroclaw plugin install 会打印某个插件包声明的目标地址,并将其写入已创建的条目中。对于之后才创建绑定的实例(例如通道别名),其出口条目会在创建该绑定时写入,所用声明和打印内容相同。安装和创建绑定都是操作人员的明确操作,打印出并持久化保存的允许列表即是其记录。zeroclaw plugin list 会显示每个实例已授予的目标地址,无需阅读配置文件即可审计其可达范围。
借助新增目标的声明进行的软件包升级不会扩展现有条目。CLI 会打印差异,由操作员手动确认应用。若不存在对应条目,则默认拒绝出站流量。
安装时播种、升级差异,以及与之对应的 zeroclaw plugin list 报告已随 #11098 落地,该 PR 取代了 #9584。绑定时播种随 #10996 中的 zeroclaw plugin bind 落地,而 zeroclaw plugin install --channel-alias 在安装过程中执行同样的流程。由于操作者在此时指定别名,绑定流程将授权视为一项明确声明的选择:--egress declared 会以与安装时相同的输出为声明播种,--egress none 会创建一个授权为空的条目;若两个参数都未提供,则会拒绝那些会从非空声明创建条目的绑定。两条命令都不会扩展已有条目。
将后续的传输绑定到同一个策略对象
当宿主采用 Socket、WebSocket 和 TLS 配置文件导入时,它们会使用同一套按实例划分的策略。对某个主机和端口发起的 socket 连接或 WebSocket 升级,都会经过同一份允许列表和相同的地址类别规则。TLS 配置文件只负责选择信任材料,绝不会绕过目标策略。无法通过此服务进行目标决策路由的传输导入将不会被合入。
在宿主提供 WASI sockets 能力之前,本记录不涉及原始 TCP 通道。在此之前,协议不是 HTTP 的通道在插件侧没有可用路径,依据 ADR-006 的能力例外规则,仍保留为原生实现。Matrix 属于此类例外,本记录不会取消该例外:端到端加密是明确缺失的宿主能力,在插件边界能够承载它之前,原生实现将继续保留。这是 ADR-006 关于能力对等性的问题,本记录不会改变它。
一项授权与协议无关。操作员授予某个主机的权限,适用于该实例的权限已允许的任何传输方式,因此不存在单独的明文维度,也不存在 egress_allow_plaintext 字段。机密性是传输层与其对端之间的约定,而不是第二份目标列表。若某协议在带内协商 TLS,则由主机掌控阶段转换,且一旦升级已开始,绝不允许回退到明文。
按实例划分的连接预算应归属于基础层,而非各个传输层。plugins.limits.max_connections_per_instance 由同一逻辑实例下的所有传输层和所有存储共享,并且经过授权的请求会在其连接的整个生命周期内持有一个租约。
在同一边界处限制不可信清单输入的大小
清单是攻击者可控的输入,与出站(egress)目标处于同一信任边界,因此低成本的清单字节不得换取高成本的宿主机工作。#9126 中交付的类型化实例配置工作相应地限定了清单所提供 schema 中的 pattern 校验:pattern 在线性时间引擎上编译,并受显式的 256 KiB 编译程序大小限制和 1 MiB DFA 限制约束;超出任一限制的 pattern 会被视为无效清单而被拒绝,而不会进行编译。拒绝该包是更安全的方向:插件作者若确实需要如此昂贵的 pattern,可以改写为更小的 pattern;而宿主一旦开始编译它,就无法再收回其调度器。之所以记录于此,是因为它加固了本 ADR 所负责的边界,而不是因为它位于出站路径上。
验收关卡
此 ADR 在满足以下所有条件之前仍处于提议状态:
- 共享的防护原语位于
zeroclaw-infra::net_guard中,两个使用方均已接入;针对插件存储的每存储钩子与固定发送路径均已交付;清单中的[egress]声明已存在,并具备解析、校验与签名覆盖;生效授权是该声明与运维人员条目的交集;安装时与绑定时的种子初始化以及升级差异确认流程均可正常工作(G1); - 必需的 CI 通过真实组件夹具来验证该边界:没有条目时默认拒绝;通过预置条目时允许;在匹配的允许列表下,元数据地址和私有地址被拒绝;当组件从允许的主机跟随重定向指向被封禁的类别时,其第二次请求被拒绝(G2);
- the first channel plugin selected by #8850 runs under a seeded entry for its API host (G3); and
- 现有实例的推广已经完成:官方注册表中的
http_client包在启用主机强制执行之前,已在重新发布的版本中附带[egress]声明;升级时的诊断信息会列出每个已安装实例被拒绝的目标地址,并给出确切的种子数据写入命令;启用强制执行的版本会在变更日志中说明此破坏性变更,#9395 已由强制执行切片(G4)关闭。
共享守卫、明文运营方字段、每请求策略读取、严格的目标地址语法、NAT64 边界、连接预算、清单中的 [egress] 声明,以及结合升级差异比对的安装时与绑定时种子数据初始化均已完成。G1 中清单 [egress] 声明与运营方条目的交集部分尚未完成,G2 至 G4 也未完成。
后果
积极后果:
- #9395 的转向通过设计本身而非仅靠签名策略来闭合,因为未签名或恶意组件无法将清单中的自我请求转化为网络可达性。
- 运营人员每个实例只需一个可审计的明文配置项,修改后会在下一次连接时生效,无需重新实例化客户机。
- 软件包升级不能悄无声息地扩大出站流量范围。
- 内置工具和插件统一采用同一套防护词汇,避免彼此产生分歧。
- 新的传输方式沿用既有的目标决策,而不是重新审议该决定。
负面后果:
- 这对每个已安装的
http_client插件都是破坏性变更:在其条目被植入或编写之前,它将无法进行出站访问。G4 正是为此而设立的,该破坏只有在迁移诊断和重新发布的声明都已就位时才会随版本发布。 - 该流程新增了一个安装时步骤和一个绑定时步骤,并为每个条目增加了策略状态。
- 需要动态目标地址的插件(例如用户提供的 URL)不会获得通配符出站访问权限。它们需要声明运营方可接受的后缀匹配模式、依赖由运营方编写的条目,或等待由宿主代理的抓取服务。这是对 #8398 的 Q4 所作回答中有意接受的代价。
- 部署在 NAT64 转换器之后时,必须声明其前缀,以保证边界判定准确;对重叠前缀进行保守评估时,可能会拒绝运维人员认为合法的目标地址。
- The策略读取是按请求进行的,而不是按实例化进行的,因此配置解析器位于连接路径上。
参考文献
- Bug #9395:插件
wasi:http出站请求没有目标策略,也没有配置项 - Migration tracker #8850
- RFC #8398:插件权限、配置与密钥模型(已关闭/已被取代的上下文)
- PR #9580:加固内置 HTTP 出站流量在共享网络防护上的实现(已合并)
- PR #9137:共享出站策略基础(已合并)
- PR #9582:在插件
wasi:http上强制执行由宿主管理的出站策略(已合并) - PR #9584:插件安装与列表的出站授权流程(已关闭,被 #11098 取代)
- PR #11098:安装时授予出站访问权限,并报告插件是否成功加载(已合并)
- 议题 #10996:在绑定时为通道实例预置配置与授权(绑定时预置)
- PR #9126:类型化实例配置校验(已合并)
- ADR-006:运行时通道插件
- ADR-009: WIT 和 wasmtime 插件执行
- ADR-012:按代次限定的实时配置应用
- 安全模型
crates/zeroclaw-infra/src/net_guard.rscrates/zeroclaw-plugins/src/egress.rscrates/zeroclaw-plugins/src/component.rscrates/zeroclaw-config/src/schema.rs- crates/zeroclaw-tools/src/helpers/domain_guard.rs
crates/zeroclaw-tools/src/http_request.rs