mcpField
G
Gage

Gage

本地服务
gage.shAI 与智能体15 天前更新

关于 Gage

Gage 是面向 Agent 的签名交接通道,将文件、提示词和决策封装在目录中。它适合需要管理发送方权限及接收端内容处理方式的用户和 Agent 运维者,信任由接收端计算,而非由交接包声明。

主要功能

  1. 01签名并封装的交接包
  2. 02由接收端控制发送方名单
  3. 03通过词组进行人工密钥确认
  4. 04对无法验证的发送方隐藏内容
  5. 05查看器、LAN 发现和脚本控制

适用场景

  1. 01将任务从 Claude 交给 Codex
  2. 02查看同事 Agent 的项目状态
  3. 03接受新发送方前要求确认
  4. 04让 Agent 通过工具管理交接

MCP 相关

`gage mcp`通过 stdio 提供 MCP,使 Agent 能以工具形式调用 Gage 的交接和信任管理功能。

配置

本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。

暂未收录安装方式

我们还没有获取到这个服务的安装包或远程地址,请按项目 README 中的说明安装。

代码仓库
安装包
mcpbgage-0.3.6-darwin-arm64.mcpb
mcpbgage-0.3.6-linux-x86_64.mcpb

工具

还没有获取到工具列表

本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。

详细介绍

gage

在 AI Agent 之间移交工作,而不移交信任。

你的 Claude 把任务交给 Codex。同事的 Agent 把他们的项目状态发给你。通常,接收方看不出是谁写了这些内容,也不知道你是否确认过这个人——而读取收件箱的 Agent,并不是在读邮件的人类。人类读到钓鱼信息,并不会执行它;Agent 却会。一个允许某个 LLM 向另一个 LLM 发送提示词的通道,默认就是一个提示词注入投递网络。

gage 是一条从根本上采用不同设计的通道。它由三部分组成:

  • 一个文件包——文件、提示词和决策,都放在一个经过签名和密封的目录中。
  • 一份由你掌控的名单。 两个人通过不受任何一方机器控制的通道比对四组词对,gage confirm 会记录他们完成了确认。一把从未确认过的密钥仍然有效,并会明确说明这一点——SENDER ON YOUR LIST, UNCONFIRMED——因为列在名单上和经过核验是两件不同的事,只有后者是在对某个人作出声明。文件包送达也不代表同意:落入你收件箱的文件包会一直保持未读,直到你接受它。(有人直接交给你的密封文件是个例外;打开时,框架会说明这一点:将目录交给 gage open 本身就是作出决定,后面没有任何确认关卡。)
  • 一个阅读框架。 在任何内容之前,接收方 Agent 都会获知:这是来自已识别方、不具备任何权威性的数据——如果发送方无法通过你当前接受的密钥追溯身份,内容就会被隐藏,而不是附上说明,因为负载可能会对说明进行辩驳。

真实终端输出,共二十五秒:两个身份从 gage init 开始,到呈现信任判定;通过词对配对并确认,通过中继发送,以及接收 Agent 在看到任何内容之前收到的框架。

录制中的每一行都是真实输出。 同一段录制内容还以带注释文本的形式保存在 docs/demo.md,以 asciinema v2 格式保存在 docs/demo.cast;scripts/demo.sh 可在临时目录中重现演示,不会接触你自己的 gage 主目录。

试试看:

curl -fsSL https://gage.sh/install.sh | sh    # verifies its own checksum
gage quickstart

它可以跨厂商使用,因为它不是协议:文件包是一个经过签名的目录,因此可以通过共享文件夹、git、电子邮件或任何你现有的方式传递。它提供查看器(gage ui);设备可以在没有中间服务的情况下通过局域网彼此发现(gage lan);Agent 也可以通过 MCP 自行驱动整个流程(gage mcp)。

gage 查看器:左侧是对话列表,打开的审核请求来自已验证的发送方,显示其信任判定、提示词和文件条目,以及回复框。

gage 是一种信物:为了担保承诺而交出的有价值之物;从历史上看,它也是为发起挑战而掷下的手套。这两层含义都很贴切。此后的一切——每项判定、每项授权、每一次无人值守的接受——都建立在那一刻的人为确认之上。

“为什么不直接自己给负载签名?”

一位审阅者提出了这个问题,但这里没有任何文档回答它。这是一个值得弥补的缺口,因为这是所有技术人员都会问的第一个问题。用任何语言实现 Ed25519 只需四十行代码。如果你想要带签名的交接,就给 tarball 签名,然后发送出去。

这样做能获得的是真实性。从这点到本方案之间的每一步,都是签名无法替你做出的本地决策:

  • **签名只能说明某个密钥签署了某些内容,不能说明你曾同意接收来自该密钥的消息。**判定结果根据你的允许列表计算;从此版本开始,还会参考一条记录,说明有人曾与密钥所有者核对过四组词对。“签名有效”和“我已确认过某人”是两种不同的说法,只有一种是在陈述某个人的情况。
  • 自行验证意味着也要自行编写判定逻辑,而那些有意思的判定,往往是直到吃过亏才有人会写的:已停用但仍在签名的密钥;无法确定应归入停用前还是停用后的数据包;你看过并拒绝的密钥。每项都需要签名一无所知的本地状态——允许列表、停用时间界限、观察计数器——而且任何一项的判断方向弄错,都会导致警告变成把窃贼的内容当作同事的内容呈现出来。
  • 负载仍须由模型读取,而这才是本方案真正关注的另一半。经过验证的负载依然是他人的文字,会在你的 Agent 接受指令的位置出现。框架、围栏随机数、逐项类型标签、信息保留规则、接受门槛——这些都不是签名能带来的,而它们共同决定了签名消息是否会变成带有证书的提示词注入载体。
  • **以及坦诚的局限:**框架是文本,而文本的优先级高不过宿主提示词。参见 docs/dogfood-2026-10-15.md,那里记录的正是这种情况。gage authority 之所以存在,是因为只有接收方才能授予权限,而且相关记录保存在发送方无法访问的文件中。

所以,如果你问的是“这是不是他们发来的”,那就给 tarball 签名。本方案要回答的是接下来的问题:“我的 Agent 可以据此做什么?”

接收方 Agent 在信任边界中的职责

关于为 Agent 执行的操作签名,已有充分的研究——对工具调用生成回执,并在执行这些调用的服务端进行验证。参见 docs/prior-art.md。gage 负责的是另一端:Agent 读取消息时,在它决定要做什么之前的几秒钟内,会获知哪些信息。

它不能阻止什么: docs/what-this-does-not-stop.md 对 2026 年的 Agent 安全事件逐一作答——Cursor 的沙箱逃逸、泄露自身密钥到 PR 标题的编码 Agent、根据 GitHub issue 标题发布后门版本的 Cline 机器人——而对每一起事件的回答都是不能,gage 无法阻止这些情况。这些内容是通过 Web、拉取请求、issue 跟踪器、电子邮件、软件包注册表传来的。gage 是两个已相互确认对方密钥的参与方之间的一条通信渠道。同一个文件还包含 MCP 服务审计结果,以及审计发现的唯一漏洞。

传输层的实际情况与局限:docs/spec.md 介绍了磁盘和网络上的实际内容——数据包布局、签名内容、接收方如何计算信任度、中继协议——这些都源自代码,而非设计意图。docs/threat-model.md 介绍了攻击者、针对每种攻击者的缓解措施,以及非目标:gage 有意不提供防护的情况,其中包括最核心的一点,即这里没有任何东西能让大语言模型服从框架。 关于 A2A: docs/a2a.md 说明了它如何对应 Google 的 Agent2Agent 协议,以及 gage 明确不是什么——不是传输协议、不是注册表,也不是 身份提供方。这是一种设计立场,而非代码:gage 目前不支持 A2A。

从 Agent 调用: docs/mcp.md——gage mcp 通过 stdio 提供 Model Context Protocol,因此 Agent 会将 gage 作为工具调用 (claude mcp add gage -- gage mcp)。词对验证流程在传递过程中依然有效: 配对需要将词对回传,而文档也如实说明了这种回传能证明什么、不能证明什么。

通过脚本驱动: docs/agents.md 介绍了无需人工操作的 gage—— 涵盖完整交接流程的命令表、退出代码(包括经过验证的情况:1 而非 2 表示 “无法读取”)、如何从输出中读取判定结果、一份完整的示例脚本,以及明确的边界: Agent 在确认密钥方面能做什么、不能做什么。

关于丢失密钥: docs/adr-identity-recovery.md 是身份恢复的决策 记录,说明了为何选择加密导出、为何拒绝可信联系人恢复(因为它会引入新的社交攻击面, 与验证流程模式相冲突),以及实现所遵循的规范。它明确说明了这项选择所带来的取舍, 威胁模型中也有说明:选择启用后,“丢失密钥,就失去一切”会变成“你的安全性取决于 口令的强度”。

这很重要,因为阅读者不同。收据的判定结果由程序在准入检查时读取,无法被说服改变 结论。数据包的判定结果则由语言模型读取,而它接下来生成的 token 就是载荷本身, 它可能会被说服。正是这种差异,构成了这个框架存在的全部理由。

因此,gage open 是渲染器,而非导入器。它会将数据包置于一个框架内,在任何载荷 出现之前声明:其中的内容是来自已识别方的数据,不具备任何权威性;如果判定不佳, 它会扣留内容,而不是添加注释,因为载荷可以反驳注释。

以下三项具体防护措施,均是在攻击测试成功后编写的:

  • 带随机数的围栏标记。 载荷文本若包含 === END RECEIVED CONTEXT ===, 原本就会关闭框架,此后的所有内容都会被视为工具本身可信的发言。实际标记会带有 每次调用随机生成的随机数,发送方无法预测。
  • 密钥代表身份;句柄只是声明。 早期版本会在已验证信任标签旁打印 sender: alice,但密钥实际属于 bob。现在会优先显示你为密钥设置的名称, 并明确指出名称不匹配的情况。
  • 信任由系统计算,不会随数据携带。 清单中没有信任字段。接收方会根据签名和 自己的允许列表推导信任,因为由发送方控制的文档中的 "trust": "verified" 键 只是一种装饰。

载荷摘要会与已签名清单进行核对,因此如果已签名清单旁的文件被替换,就会被发现, 内容也会被扣留。

哪些判定结果会扣留内容,具体来说。 这值得明确说明,因为本 README 的早期版本 曾写道“判定不佳时”,却让读者自行猜测具体范围——而大多数人的猜测都是错的。摘要 不匹配时,以及出现 SENDER UNVERIFIABLE、SENDER KEY REJECTED 和 ARRIVAL UNRECORDED 时,内容会被扣留:这些情况都无法将责任归于任何人。对于 SENDER NOT ON YOUR LIST、SENDER REFUSED BEFORE 和 SENDER ON YOUR LIST, UNCONFIRMED,内容会显示出来,判定结果则显示在内容 上方。这些情况都能追溯责任——签名确实来自真实密钥;扣留内容会让与新认识的人进行 普通的首次联系变得不可用,却没有任何好处:把文件交给你的人,可以再把文件交给你。 你得到的替代方案是:每次都会在载荷之前显示判定结果。 新鲜度和运行上下文也是判定事实。 一个捆绑包可以包含发送方设置的过期时间(--ttl / --expires),以及声明的上下文(--context),用于指定它所属的运行。过期的捆绑包会加入上文所述的暂扣集合;它仍留在收件箱中,列出并标记,因为收到并不等于接受。如果此计算机知道自己的运行上下文(GAGE_CONTEXT,或 <home>/context 的第一行),判定还会在签名旁注明比较结果——匹配、不匹配、未声明、无本地上下文——并显示在框架、收件箱、查看器和 JSON 中。匹配并不代表授权;它只是排除一种特定错误:把昨天收到的交接内容当作今天的内容来读。具体语义见 docs/spec.md;相关限制见 docs/what-this-does-not-stop.md。

大声说出密钥

$ gage whoami
owen	yomSmC3TalJ5IM+H0yvkhhWVab4ujiAVIQFR4WvdXiM=.KlXt+/5rj4y3FMC7eqau4zF5eZV+x7PjYZUdyUXHDEM=

  此密钥表示:雕琢的水仙 · 高耸的门楣 · 香料大麦 · 明亮的鬣蜥
  设备标签    :谦逊的柱子(仅根据签名密钥生成,因此不是上方的第一
                    组词——它是其他人区分机器的方式)

LocalSend 用一组双词标签标识设备,这是它最讨人喜欢的地方——但在 LocalSend 中,这些词是设备自行选择的名称,所以任何人都可以叫“黄色鬣蜥”。而在这里,这些词由签名密钥生成,因此没人能自行选择;显示相同词语的两台机器持有的就是同一密钥。gage fingerprint <name> 可以查看联系人的指纹,而 gage add 会打印刚刚添加的密钥对应的词语,这时你应通过其他渠道与对方确认。

需要两种长度,因为一种长度无法胜任两种用途。一组词只有 16 位:看着不错,却不适合作为校验,因为攻击者可以不断生成密钥对,直到自己的密钥也显示为“谦逊的柱子”——大约需要尝试 65,000 次。因此,这组词是用于在列表中区分设备的标签,绝不会用它来确认身份。指纹由四组词组成,共 64 位,供人们比较。

它们来自同一个函数,因此对于没有加密密钥部分的密钥,标签就是指纹的第一组词。而对于上面的密钥则不是——再看看那两行。指纹涵盖签名密钥和加密密钥,因为如果只确认签名部分,攻击者就可以替换接收邮件时用于加密的密钥,仍然逐字通过词语比较。标签只涵盖签名密钥,而且必须如此:捆绑包的清单不包含其他信息,因此对于接收方从未见过的发送方,接收方唯一能生成的昵称就是这个标签。

本段过去曾声称标签“始终是指纹的真实前缀”,spec.md 和 words.rs 也都这么认为,示例也按此编写——这就是错误说法如何在三份文档中留存的。对于任何你可以加密发送内容的联系人,这种说法都是错的。现在有一个单元测试会在两者再次一致时失败。

第二台机器

安装它,然后在每台机器上运行一个命令:

$ gage lan
新身份,名称取自其自身密钥:grey crowbar
  grey crowbar · lavish jackdaw · sleepy hedgerow · electric delta

gage lan ——你在此网络中的名称是 "grey crowbar"
  运行期间可见且可访问,停止后立即静默
  发送给你的任何内容都会作为未读消息进入收件箱/待处理
  广播到:255.255.255.255、192.168.1.255

  gallant alcove  位于 192.168.1.44:7791  (未配对)
      显示为:gallant alcove · indigo burrow · merry banner · glassy nettle

然后根据列表中的两个词发送:

$ gage send --to "gallant alcove" --subject hi --file notes.md --lan
已通过此网络发送给位于 192.168.1.44:7791 的 gallant alcove
它已进入对方的收件箱/待处理——尚未接受

无需选择名称,无需复制密钥,也无需预先配对,更不需要中继。名称由签名密钥生成,包含两个词,因此没人能自行挑选——就像 LocalSend 的设备名称,只不过名称是推导得出的,而非自行选择的,因此无需用户付出任何代价,就能成为更好的标签。

进程即在线状态。gage lan 在前台运行时会广播,停止后立即静默。这正是 LocalSend 对同一异议的回应,也比最初引入的那个标志更好:状态会显示在屏幕上,而不是藏在一个你必须记住已经过了多少秒的数字里。

**配对是升级,而不是门槛。**未配对的机器可以向你发送内容;收到的内容会标记为 SENDER NOT ON YOUR LIST,保持未读,并受与其他所有内容相同的接受门槛保护——这就是保护机制,而且一直都是。想要确认密钥,而不只是查看它时:

$ gage pair laptop
  gallant alcove  位于 192.168.1.44
  显示为:gallant alcove · indigo burrow · merry banner · glassy nettle

另一台机器显示的词是否完全一致?[y/N] y

四组词对共有 64 位信息,也是人们进行比对的内容;列表中的单组词对只有 16 位信息,只是一个标签,绝不能用来确认身份,因为要试遍 65,000 个密钥对来匹配一个标签轻而易举。只有经过这样的确认,密钥才真正属于你,而不只是属于这张 Wi-Fi 网络上的某台机器;Agent 在无人看管时对消息采取行动前,授权就应该要求这一点。要求在传输一个文件前先完成确认,是我后来加上的一步,而且这是错误的。

底层的 gage nearby、gage pair 和 gage listen 仍然可用,作为显式操作;如果机器应该在不广播的情况下保持可访问,就使用 gage listen——只有当地址查询由你列表中已有的密钥签名时,它才会响应,因此陌生人听到的只是静默,无法分辨这与网络上没有设备的情况。设计与取舍: docs/lan-discovery-design.md。

授权:唯一能表明你可以采取行动的东西

上面讨论的都是已知的信息。它们都没有说明 Agent 是否可以执行消息中的要求——也无法说明,因为无论签名多么完备,数据包都不能授权履行自身的内容。这个缺口确实存在,而且经过了测量:在四个 Agent 上进行内部试用时,其中两个读取了来自已验证联系人的已验证请求,却因缺少授权而正确地拒绝了。

因此,授权由接收方根据自己的列表计算,和信任一样。~/.gage/authority 是一份由你本人编写的文件:

# 没有 gage 命令会写入此文件;请自行编辑
allow codex review-request
allow dana question in-reply-only
allow <a-public-key> any          # 任何消息 KIND,而非任何操作

gage authority 会显示已授予的权限。这里刻意不提供写入授权的命令——gage allow 可能被提示词注入调用,让 Agent 在被诱导运行该命令后,授予自己权限以服从诱导它的内容。请自行编辑文件。

这项防护只是威慑,并非安全边界;如今说明框也明确指出了这一点。任何能读取该文件的进程也能写入它,因此拥有 shell 的 Agent 可以为自己添加授权——而 10 月 15 日评审所用的授权正是这样写入的。移除文档中记载的命令后,被注入的 Agent 必须先被要求手动编写文件,这在对话记录中会很显眼;但文件仍然可以被写入。真正的安全边界需要依靠文件权限,或一个 Agent 无法写入的独立进程。因此,open 会告诉读者,绝不要编辑此文件或添加授权,因为消息提出了这样的要求。

三条规则,按违反后果的严重程度排列:

  • 只有在 SENDER VERIFIED 后才会考虑授权。 若得到其他任何判定,系统根本不会查询授权。若授权依据的是伪造或被盗用的密钥,后果会比没有这项功能更糟。
  • 格式错误的文件不会授予任何权限。 只要有一行无法使用,整个文件就作废,因为反过来处理会让第 2 行的笔误扩大第 3 行的权限范围——而这两种错误的后果并不对称。权限过少时会向人询问;权限过多时则会执行陌生人的指示。
  • 授权针对的是某种消息类型,说明框会指明它允许的唯一操作。 授权范围由消息类型决定,定义在 MsgType::granted_scope 中:review-request 允许读取数据包所包含的内容并报告发现,不允许做其他任何事。第一个版本曾写道“你可以在消息要求的范围内采取消息要求的行动”,这就把边界交给了消息负载;两位评审者分别独立发现了这个问题。文件编辑、运行命令、提交、推送、发送任何内容、联系第三方,以及接触机密信息或密钥,都被列为绝不受授权涵盖的操作,无论消息负载如何要求。
  • 授权行包含说明框的 nonce,因此消息负载无法编写出完整的授权行。这提高了对抗复制静态字符串的门槛。但它并不能让正向授权无法被仿冒,因为读者是一个可能被说服而忽略标记的大模型;说明框会明确指出这一点,而不是依赖该机制。

目前有意不设过期时间——它无法使用时钟,原因与密钥退役无法使用时钟相同;而“在观察到 N 条消息后过期”这种要求也很奇怪,不适合让人手动编写。你编辑的文件本身就可以通过再次编辑来撤销授权。

无需人工即可接受,以及用于限制它的预算

accept 一直表示由某人查看说明框并移动数据包。对于无人值守循环,这是无法自动完成的步骤,因此人们会改用自行运行 gage accept 的钩子——这样一来,每项授权都没有限制,而且 gage 事后无法区分哪些接受操作是由人执行的。因此,这项功能设在工具中,以便进行计数:

allow sndr answer auto-accept max-exchanges 4
$ gage watch --auto-accept
  7382e954: 已自动接受,当前线程中 1 次,共 4 次(允许 sndr 仅在回复时回答
            自动接受,最多交换 4 次)
  aa3e2665: 此线程已用完 4 次自动接受中的 4 次(……),因此仍保持待处理状态

必须同时满足以下四个条件:

  • 启动 watcher 时使用了 --auto-accept。 文件说明人类允许什么;标志说明此进程将无人值守运行。交互式 gage watch 不得移动读者正要查看的内容。
  • 授权规则声明了 auto-accept。 没有 max-exchanges N 就不能写入该规则,也不能针对 any 写入。这两种情况都表示拒绝,而非默认值:写了一半的授权规则不能被视为不受限制的授权,而这里的任何默认值都是在猜测当事人的意图。
  • 它回复的是此机器发送的内容。 auto-accept 隐含 in-reply-only,并且显示给你的授权规则会打印这个隐含修饰符,因此呈现的行就是实际生效的内容。
  • **线程的预算尚未用完,**按日志计算。

发送方无法补充预算。 预算按线程计算,而线程 ID 是由发送方填写的字段,因此应从此机器自己的 sent/ 中读取作为键:也就是正在回复的父消息所属的线程,只有接收方能写入该线程。回复必须指向其中存在、且由此机器自己的密钥签名的父消息,因此开启线程是接收方的行为;发送方若指定从未发送过的父消息,就什么也得不到。

最初的版本声称,隐含的 in-reply-only 就是全部防护措施。审查者指出,这甚至称不上半套防护:in_reply_to 和 thread 是独立字段,因此获准的发送方可以回复同一个已验证的父消息,先写 thread: "aa",再写 "bb",然后写 "cc",每次都领取完整预算。验证父消息后却丢弃从中获得的所有信息,才留下了漏洞——谓词只返回了一个布尔值。现在它会返回父消息,并以此为预算键。如果回复声称的线程与其所回复的父消息所属线程不同,也会被直接拒绝,因为使用 --reply-to 构建的发送方绝不会这样做。

这项检查也让 in-reply-only 真正有了意义。在此功能需要它之前,它只检查 in_reply_to.is_some()——也就是问发送方是否正在回复。任何发送方只需在该字段中写入任意字符串就能满足条件,因此写了 in-reply-only 的授权规则并不比没写的更严格。该字符串还会成为路径组件,因此 ../../../../etc 会在执行 stat 时越出 sent/;现在会通过与 pull 上使用的同一个函数验证 ID,同一个漏洞以前也曾在此被发现。

日志条目就是预算。 没有计数器文件:在第二处保存计数,就多了一处可能出错的地方;而操作记录本身就是使用量记录。每次无人值守接受都会写入允许该操作的授权规则、文件中的对应行、预算键,以及当时的计数。

由此带来三点改进,而每一点最初都对应一个缺陷:

  • **移动内容前先写入记录,**这样写入失败时,内容会留在 pending 中供人处理,而不会进入 accepted 却没有任何计数记录。若顺序相反,就会出现未扣除预算却已接受的情况。
  • **在日志锁内重新计算计数,**因为先计数再追加分两步执行会产生竞态:两个 watcher 都会针对 max = 1 读到 used = 0,然后都接受。
  • **日志无法写入时,本次运行剩余时间内会锁定关闭自动接受。**以前程序只会打印错误并继续运行,因此磁盘已满时,只要磁盘满的情况持续,自动接受就会变成不受限制且不计数的通道。无法解析的行也会导致拒绝,而不是计数时越过该行——read 会跳过无法读取的行,这样做适合显示历史记录,但用于计数器时则会导致故障时默认放行。 **它不是什么:**允许采取行动——但有一条坦诚的限定。接受操作本身会移动一个目录并将其标记为可读;它不会打开包、将包交给模型,也不会运行任何内容,而 gage open 的 authorised : 区块仍然是唯一说明可以对其中内容做什么的地方。不过,gage watch --auto-accept --on-arrival … 在一个进程中构成了端到端的无人值守流水线,而且接受操作被有意安排在钩子之前,这样钩子发现包时,包已经被接受。一位审阅者指出得没错:“不会打开或运行任何内容”这句话对接受操作本身成立,对这种组合则不成立。钩子接下来能做什么,受钩子本身(由人编写)以及告知它的授权范围所约束,而不是受此功能约束。

这个限制也无法抵御蓄意的已验证发送方——在预算范围内,它仍可以无限期地回复你发送的所有内容。它的作用是让失控的交互——两个 Agent 互相回复、陷入循环,或发送方卡住——在一个由人选择的次数上停止,而不是等磁盘先耗尽。

现在,gage watch 还会在任何操作发生之前,将到达时间记入日志。以前不会这样,这意味着无人值守路径——日志存在的意义所在——留下的记录最少:只有一条 accepted 记录,前面没有 observed 记录。

开始使用,以及帮助其他人开始使用

无需 Rust 工具链即可安装:

curl -fsSL https://gage.sh/install.sh | sh

这会把脚本通过管道传给 shell 执行,这种做法值得先确认是否可信——所以,如果你愿意,可以先阅读脚本;它很短,一分钟就能读完:

curl -fsSL https://gage.sh/install.sh -o install.sh
less install.sh && sh install.sh

它会检测你的操作系统和架构,从最新版本下载匹配的二进制文件及其 SHA256SUMS,在安装任何内容之前验证校验和,然后将一个文件放入 ~/.local/bin(使用 PREFIX= 可更改位置)。它从不使用 sudo;如果某个平台没有已发布的二进制文件,它会明确报错,而不会猜测。cargo install gage 仍是另一种安装方式,凡是 Rust 能运行的地方都能用。

10 月 18 日,两次更名:ctx → countersign → gage。磁盘上的任何内容都不会被移动,永远不会。 身份密钥一旦丢失便无法补救——每位确认过你那组词对的联系人,确认的都是这把密钥——因此 gage 会使用 ~/.gage、~/.countersign、~/.ctx 中第一个已存在的目录,不会复制任何内容,也不会创建目录来替代其他目录,并会遵循 GAGE_HOME、COUNTERSIGN_HOME 和 CTX_HOME,确保现有脚本和 systemd 单元继续正常工作。

然后,从零开始,回答四个问题,即可完成第一次交接:

gage quickstart

如果你还没有身份,它会为你创建一个,展示你的词对以及发给同事的一行命令,然后询问你是否要添加对方的密钥并发送第一个包。每一步都可以跳过;它不会做任何下面的独立命令做不到的事。

或者,使用中继:

gage connect https://relay.example.com

先完成三个无需做决定的步骤——创建身份、注册邮箱、确定要分享什么——然后在需要做决定的那一步有意停下来。它会打印对方要运行的命令,以及你必须大声读给对方听的四组词对。最后这一步没有自动化,因为自动化它就等于删掉 gage 唯一真正做的事。

随时可以再次运行:它会保留你现有的身份。一个旨在帮你开始使用的命令,绝不能悄无声息地丢弃你此前所有交换内容赖以建立的密钥。

把它交给别人

scripts/release.sh my-laptop     # 在每台可访问的机器上构建
cat dist/SHA256SUMS
scripts/release.sh --audit       # 这个版本真的发布了吗?

二进制文件会放在 dist/ 中,每个平台一个,并且都在运行它的机器上构建——交叉编译需要为每个目标平台准备 C 工具链,因为 rustls 会用到 C;在二进制文件实际运行的机器上构建,出错的可能性也就少了一分。每个二进制文件都会报告其来源提交,gage --version 会打印该提交;发布脚本拒绝从有未提交更改的工作树构建:标注的提交与实际不符的二进制文件,还不如完全没有版本信息;而日志会冻结判定结果,因此“这是哪个 gage 作出的判定”必须有确切答案。版本戳通过询问二进制文件来检查——如果它无法在当前环境运行,就在构建它的主机上检查。

INSTALL.md 从对方的视角编写,特别说明了他们最可能放弃的两个环节:macOS 上未签名二进制文件的隔离提示,这会让人觉得软件坏了;以及词组确认,感觉像是个麻烦步骤,尽管它是唯一无法自动化的环节。

使用

gage init owen                      # 生成你的密钥对
gage whoami                         # 提供给同事的那一行信息
gage add dana <their-key>           # 好友列表;以密钥为准,而非名称
gage confirm dana                   # 大声对照词组后:唯一能产生 SENDER VERIFIED 的操作
                                    #   唯一能产生 SENDER VERIFIED 的操作
gage rotate                         # 替换你的密钥,同时保留过往记录

gage send --to dana --subject "backoff refactor" \
         --prompt task.md --decision notes.md \
         --file src/client.rs \
         --agent claude-code --model claude-opus-5 \
         --out ./handoff

gage send --to dana --subject "tonight only" --ttl 7d \
         --context "backoff refactor, run 12" --file src/client.rs
                                    #   由发送方设定的到期时间,以及
                                    #   这个数据包所属的运行记录

gage inspect ./handoff              # 查看摘要,不会将内容放入上下文
gage open    ./handoff              # 在框架内渲染,供 Agent 使用
gage reply   <id> --text "..."      # 回复讨论串,自动推断接收方
gage forward ./handoff --to group:eng --out ./fwd

gage lan                            # 一条命令覆盖整个本地网络
gage send --to "grey crowbar" --file notes.md --lan
gage pair laptop                    # 确认密钥,而不只是查看它

当数据包可归属时,open 以 0 退出;如果签名失败、有效载荷不匹配、签名密钥已停用,或数据包的发送方设定到期时间已过,则以 2 退出,这样调用程序无需解析文字就能分支处理。

条目类型及其区别

类型含义open 如何处理
--file / --dir材料:源代码、数据、文档作为数据围栏
--decision值得留存的结论作为数据围栏
--prompt用于引导模型的文本严格围栏:引用,绝不采纳
--memoryCLAUDE.md、AGENTS.md、memory/*.md最严格的围栏:绝不合并

Memory 单独作为一种类型,因为只有它会在接受它的会话结束后继续留存。提示词只引导一轮对话;合并后的 memory 则会引导此后的每个会话,即使批准它的人已不再监督。open 会明确说明这一点,绝不会建议合并。

--dir 会按原有结构发送目录树。早期版本会把所有内容都压平到 items/<basename>,因此包含 src/mod.rs 和 src/net/mod.rs 的项目会在同一路径生成两个条目,后者会悄然覆盖前者——发送项目会造成内容丢失,却没有任何提示。构建和缓存目录会被跳过,看起来像机密的内容也会被跳过(.env、*.pem、*.key、.netrc、id_rsa*,以及名称中包含 secret/token/apikey 的内容)。每项跳过的内容都会在构建捆绑包前打印出来,因为静默遗漏本身就是一种意外。--allow-secrets 可以覆盖此限制,但你应该有充分理由。

限制:每个条目 2 MB,每个捆绑包 25 MB。

--reply-to <id> 会将捆绑包关联到它所回复的那一个,这样两个 Agent 之间的交流就会呈现为一段对话。

加密

签名可以证明是谁编写了捆绑包,但不会隐藏其中的内容。在自己的文件系统上这没问题,在服务器上则不行,因此任何内容都不会以未封装状态发送到中继。

gage seal ./handoff --to dana --out handoff.gageb
gage unseal handoff.gageb          # 接收:收件箱、接受门槛、回复
gage inbox

不带 --out 的 unseal 会接收信封:它会经过与中继转交的邮件相同的门槛——安全 ID、验证签名和防重放凭据——然后以未读状态存入 inbox/pending。因此,通过 U 盘文件发送给你的捆绑包也和其他邮件一样:gage inbox 会列出它,gage accept 会暂存它,gage reply 会回复它,而且到达事件会被记录并写入日志。

--out 则是另一种操作,且有意与接收不同:

gage unseal handoff.gageb --out ./incoming   # 检查,但不要接收
gage open ./incoming

这会将文件写入你指定的位置,不会记录任何内容。你可以用它查看刚创建的捆绑包,或尚未决定是否接受的捆绑包。对结果运行 gage open 时会显示它并非通过收件箱到达;由于没有到达记录,一旦发送方停用该密钥,以这种方式检查过的捆绑包就会显示 ARRIVAL UNRECORDED。这是诚实的结果,而非记录缺失:凭据表示这台机器接收了某些内容,因此只有接收操作才能写入凭据。如果检查操作也记录了到达事件,仅仅查看文件,日后就能让已停用的密钥获得较轻的判定并显示其内容。

密封盒(X25519 + XSalsa20-Poly1305)使用新生成的临时密钥对,以接收方的公钥加密。信封包含三个字段:版本、接收方的加密密钥和密文。因此,中继只能获知接收方密钥、大小和时间,而无法获知主题、文件名、发送方或载荷。通过对真实信封运行 grep 搜索发送方标识、主题和内容进行了验证:这些信息都不会出现。

传输是匿名的,作者身份由捆绑包内的签名证明。这种划分是有意为之:端到端的作者身份验证可以跨越中继,而中继永远不会成为判定谁说了什么的权威。unseal 会明确说明这一点,因为解密只能证明发送方知道你的公钥——而任何人都知道它。

解密后的内容仍是不可信输入。路径超出捆绑包范围,或未在清单中声明的条目都会被拒绝写入;这两种情况都有单元测试,因为只有恶意的发送方实现才能生成这类条目。

现在,你的 gage whoami 令牌是 signpub.boxpub。在加密功能推出前创建的身份,下次加载时会获得一个加密密钥,而签名密钥保持不变,因此你之前签署的每个捆绑包仍可验证。

没有记录加密密钥的联系人仍可验证,但无法作为密封目标;gage seal 会直接拒绝,而不会悄悄以明文发送。

出站安全

扫描会读取字节而非文本,并通过重叠窗口扫描长行。这两项都是修复:以前它会用 read_to_string 读取,并跳过所有不是有效 UTF-8 的内容,因此在文件末尾追加一个无效字节,就会让整个扫描忽略该文件;超过 4000 字节的行也会被跳过而不是截断,因此只要填充一行,就能隐藏其中的内容。一个能被它所扫描的对象关闭的扫描,只是摆设。

扫描结果会被脱敏为固定格式,既不透露末尾内容,也不透露长度——最初的版本会显示开头四个字符、末尾三个字符,以及一个与实际长度完全相同的星号数量;对于较短的令牌,这几乎就是令牌本身,而且会在拒绝操作前打印出来。

符号链接会被拒绝并报告,绝不会跟随。拒绝列表匹配的是文件名,而链接的名称并不能说明其目标:notes.md -> ~/.ssh/id_rsa 通过了所有检查并泄露了密钥,内容扫描也没能拦住它,因为形似 .env 文件的目标既不是 PEM 块,也不符合供应商密钥模式。--allow-secrets 不会覆盖这一规则——它只针对名称可见的文件,而链接的全部问题就在于目标不可见。通过 --dir 指定链接目录会报错,而不是跳过,因为遍历它会收集来自无人指定位置的整个目录树。

出站前有两道关卡,因为文件名拒绝列表只能拦截那些名称看起来像机密的文件。

内容扫描。 每个项目在封装前都会被读取。真正会造成泄露的是引用客户地址的 notes.md,或仍包含 bearer token 的日志片段——文件名无法说明其中的内容。gage send 会拒绝操作,而不是发出警告:对于出站路径,警告发出时,文件包已经生成;如果收件人位于远程端,内容也已经发出。

outbound scan found 1 item(s) that should not leave:
  notes.md:2  [email]  dana**********************.uk

扫描结果会被脱敏。用于阻止机密外泄的工具,不应该在拒绝操作的同时把机密打印到终端或 Agent 上下文中。内置规则涵盖私钥块、AWS/Anthropic/OpenAI/GitHub/Slack/Google/Stripe 密钥格式、bearer token 和真实邮箱地址;example.com 和正文中单独出现的 sk-ant- 不会触发规则。~/.gage/scrub.json 可通过 never_send 指定字面值(例如客户域名、代号),并通过 disable 禁用仓库中确实会触发的规则。--allow-findings 会覆盖限制并说明这一点。

内容发出前的确认。 gage push 会打印收件人、主题和文件列表;如果没有 --yes,就会拒绝操作。接受关卡保护的是接收方;这道关卡保护的是发送方,而当发送操作由 Agent 执行时,这才是关键。

这道关卡设在 push 上,而不是 send 上,因为 send 要么投递到本地路径,要么写入一个仍需你手动移动的目录——此时内容尚未离开。早期版本会在“没有本地路径”时拦截 send,但“没有本地路径”不等于“内容即将发出”,这也导致所有发送到未路由句柄的操作都无法进行。

中继

注册证明的是持有信箱 secret,而不只是知道信箱密钥。你会把这个密钥写在 gage whoami 行中并交给别人,因此,如果中继仅凭声明就接受注册,别人的信箱就可能被抢占:在对方注册之前,先用你自己的签名密钥注册对方的信箱密钥,而先注册者优先的规则会永久将所有者拒之门外——他们永远无法获取消息,发给他们的邮件也会落入抢占者手中,由其收取并删除。邮件经过密封,因此永远无法读取,但已经消失了,而发送者还以为邮件已送达。

因此,中继会用对方提供的信箱密钥封装一个随机随机数,并且只有调用者发回该随机数时才会确认绑定。抢占者会得到一个无法打开的密封随机数,以及一个不会阻止任何人的待处理声明,因为待处理声明按密钥对而非信箱进行索引。

用于另一位 Agent 不在你的文件系统中的情况。中继刻意设计得很简单:它保存发往 X25519 公钥的密封信封,并交给任何能证明自己持有对应密钥的人。

你使用的中继

命令行没有默认中继。 运行 gage join <address> 之前,gage push 和 gage pull 会拒绝执行,也不会为你指定地址。

查看器则不同,而且此前从未记录过这一点,直到有人在审查时提出。 Conversations 中的“可通过互联网访问”开关会预先填入 https://relay.gage.sh(RELAY_DEFAULT,src/ui.rs)。这是可编辑字段中的建议值:按下按钮之前不会联系任何服务;按下后,系统会在那里注册你的邮箱。如果你有自己的地址,可以清空该字段并输入自己的地址;在你控制的任何主机上运行 gage relay --bind …,就能启动完整的服务端。

无论你使用哪个中继,包括这个中继,以下属性都是你所依赖的,也是默认中继尚可接受的原因:它只保存无法打开的密封信封,而它能获知的只是元数据——哪个邮箱有邮件、邮件有多大、何时收到。如果这些元数据对你很重要,请运行自己的中继;如果另一台机器在你的文件系统或网络上,则完全不需要中继,gage seal / gage lan 是更简单的方式。

在存放邮件的主机上:

gage relay --bind 100.x.y.z:7788

在每台机器上:

gage join 100.x.y.z:7788        # 注册你的邮箱并保存地址
gage push ./handoff --to dana   # 密封后上传
gage pull                       # 收取邮件到 inbox/pending

中继无法读取它所存储的内容。 如果中继被攻破,泄露的只有元数据——哪个邮箱有邮件、邮件有多大、何时收到——而没有内容。测试套件对此进行了验证:推送一个主题和内容字符串都很独特的捆绑包,然后在中继的存储中搜索;两者都不会出现。

中继强制执行以下四项属性,每项均经过测试:

  • 收取邮件需要证明持有密钥,而不是提供密钥名称。 任何看过 gage whoami 输出的人都知道你的公钥,因此仅凭名称进行身份验证会让陌生人清空你的收件箱。中继会发出一个随机挑战,并要求对其进行 ed25519 签名。
  • 挑战只能使用一次。 可重放的签名就是有效期更长的被盗凭证。
  • 挑战绑定到一个邮箱,因此无法将其转向其他邮箱。
  • 邮箱的首次注册优先。 将邮箱密钥重新绑定到不同的签名密钥,会通过重新注册接管邮箱。

Base64 密钥会用作目录名称,因此系统会验证它们,而不会盲目信任:Base64 包含 /,而路径组件中的 / 会让邮箱逃出其目录。系统会拒绝未注册的邮箱,因此中继不能被用来存储无人收取的密钥。

将它绑定到 tailnet 地址,而不是 0.0.0.0。在 tailnet 上,WireGuard 已提供加密通道,且不会暴露到公共网络,因此不需要 TLS,也不需要域名。安全性不依赖网络——无论如何,负载都是密封盒——因此之后同一个二进制文件可以放在公共地址上的反向代理后,无需更改信封。

信任仍来自捆绑包内的签名,而不是中继。通过中继收到的捆绑包,如果发件人尚未添加到你的列表中,就会显示 SENDER NOT ON YOUR LIST,与在本地收到时完全相同。

一个面向他人的中继

上文关于中继的所有内容,都假设它只能从一个人的 tailnet 访问,而目前它一直如此运行。让一位同事也能访问,会彻底改变威胁模型,而原先缺少了三项防护。

一个什么也不发送的连接,就能让整个中继永远无法响应。 接受连接的循环是顺序执行的,read_line 会在其中阻塞:没有凭据、没有发送任何内容,仅一个 TCP 套接字就能造成完全拒绝服务。在查看器中发现同类问题时,已经为它设置了超时;中继却一直没有,而中继才是面向他人的部分。现在它会在读取任何内容之前设置读写超时,并发处理连接且设定上限;超过上限时会礼貌地拒绝连接,而不是崩溃。

/send 有意不要求身份验证,因此需要设置上限。 陌生人必须能够给你发送第一条消息——这正是好友列表中“未在列表中”部分的用途——因此解决办法不能是身份验证,而是配额:每个邮箱的字节数和信封数上限、在许多邮箱各自都未超限时作为后备保护的总存储上限,以及每个地址的请求速率上限。这四项都是 gage relay 的标志,因此想提高上限的运营者可以这样设置:

gage relay --bind 0.0.0.0:7788 --max-mailbox-mb 64               --max-mailbox-msgs 500 --max-store-mb 4096 --rate-per-min 120

邮箱已满时会被拒绝,并显示“邮箱已满”,不会透露是谁填满了邮箱。信箱不会把寄件人告知敲门的人;只有收件人在取信时才能看到寄件人。收件人仍然可以领取已送达的信件——对陌生人的限制不应变成对你的限制。

绑定到回环地址或 tailnet 地址以外的任何地址时,服务在开始接收数据前会说明这意味着什么。

TLS,以及即使负载已加密,为何仍需要它
gage join https://relay.example.com      # 验证证书
gage join 100.64.0.1:7788             # 不使用 TLS:适用于 tailnet 或回环地址测试

由 scheme 决定,且地址由人输入,因此这个选择清晰可见,而不是隐藏在默认设置中。

负载一直都是加密的,TLS 不会改变这一点。它改变的是负载之外的一切。/send 会以明文传输收件人的邮箱密钥,因此路径上的任何人都能知道你写给谁、写了多频繁,以及信件有多大——内容仍然是加密的,但通信关系并非如此。没有服务器身份验证时,任何能够冒充该地址作出响应的对象都可以接收你的推送,返回 {"ok":true},然后将其丢弃:内容无法读取、消息也未送达,而发送方却被告知操作成功。签名保护内容;它们从未保护过投递过程。

验证使用编译时内置的 Mozilla 根证书列表——而非系统证书库,因此使用私有 CA 的中继会验证失败,而不会悄无声息地正常运行。测试套件会通过 badssl.com 双向断言这一点:过期、自签名、主机名错误和不受信任根证书都各自因对应原因遭到拒绝,而有效证书则能完成握手。最后这项检查并非装饰——即使清空根证书库,导致任何证书都无法验证,四种拒绝结果中仍有三种看起来正确,因为“已拒绝”并未说明原因。

这些检查需要网络,因此在网络不可用时会跳过——并明确打印跳过信息,因为悄无声息地通过一项实际并未执行的安全检查,正是这个测试套件要防止的错误。

代价:增加 29 个 crate,使总数从 117 增至 146。对于一个其余七个依赖全是密码学原语的项目而言,值得说明。

Replay

密封信封就是一个文件。任何能够访问中继的人——包括中继本身——都可以把同一封信交出去两次,而其中的内容确实是真的,这恰恰是它危险的原因:重放的指令是真实的指令,而且已经执行过。

过去,投递时只检查 ID 是否仍在 pending 中。一旦某个包已被接受,这条路径就不存在了,因此同一封信会再次作为新到达项暂存——按需无限重来——而日志里完全没有记录。现在,gage pull 会拒绝它并写入一条 duplicate 记录,这是日志中唯一一条记录机器拒绝执行某事的事件。

检查依据是见证值,它此前就已被记录: sha256(from.key || 0x1f || manifest bytes)。它标识的是这份清单,而不只是这个 ID,因此,善意重发的发送方不会受到影响——新消息至少会有新的 created,所以见证值也会改变。测试套件会验证这两种情况,因为这里最容易矫枉过正,拒绝任何曾经联系过的发送方。

对话

一个包会携带发送方希望你做的事:

gage send --to codex --subject "key rotation" --type question --decision q.md
gage send --to owen  --type answer --reply-to <id> --decision a.md

share(默认)、question、answer、review-request、ack。未知类型会被拒绝,而不会悄悄使用默认值。question 和 review-request 会把待回复的一方留给收件人,gage inbox 会说明这一点。

gage threads          # 按对话分组,最新活动优先
gage threads --mine   # 仅显示待你回复的对话
gage thread <id>      # 查看单个对话,先显示根消息
gage close <id>       # 在你看来已解决

三条规则,每一条都是因为看似直观的做法并不正确:

  • 已读状态和对话状态均为本地状态。 发送方不能在你的收件箱里将自己的消息标记为已读,也不能因为自己希望如此,就宣布对话已得到回复。与不把信任信息放进清单的原则相同:发送方控制的一切都只是一种声明。
  • 对话状态由消息推导,而非存储。 如果对话中的最新消息要求回复,且来自其他人,那么这条对话就在等待你回复。无需维护状态,因此常见情形不会发生偏差。
  • 已发送消息会保存在本地。 只由对方那一半消息拼成的对话并不算对话——早期版本会显示一条没有上方问题的回答,并取用回复消息的主题。它也兼作审计记录:双方都持有一份副本。

对话的主题取自 ID 就是对话 ID 的那条消息,而不是排序后排在最前面的消息。created 的精度只有一秒,因此问题和回复可能在同一秒发出并导致顺序颠倒,对话也就会以回复的主题命名。

打开消息会将其标记为已读;列出消息则不会。在收件箱里看到主题,不等于已经读过消息。

回复

gage reply <thread-or-message-id> --text "Full jitter, cap 30s."

ID 可以是线程或单条消息,使用唯一前缀即可。 如果线程中有两位通信对象,系统会拒绝操作,而不是猜测目标。这种拒绝修复了一个真实的攻击问题:thread 是发送者填写的字段,因此任何看过某段对话中一条消息的人都能发消息到该线程,成为“最新的非本人消息”,并收取原本发给别人的回复。指定消息而不是线程不会产生歧义;如果你持有线程根消息,指定线程就是指定那条消息。对于无法根据本地持有的任何内容核验其线程归属的消息,列表、gage thread 和查看器都会将其显示为声明。

其他信息都会自动处理:收件人取自为被回复消息签名的密钥,绝不取自发送者填写的 from.handle 或 to 列表。如果父消息预期会收到回复,类型默认为 answer;否则默认为 share。主题使用线程自身的主题,不会重复添加前缀。

--text 会写入文件,并像其他项目一样发送,因此匆忙输入的回复也会经过与文件相同的出站扫描。若前缀匹配到两条消息,系统会拒绝操作,而不是擅自选一条:回复落入错误的对话,且输出中毫无提示,比报错更糟。回复自己的消息时会明确说明这一点,而不会报告你的密钥不在列表中。

查看器

gage ui            # http://127.0.0.1:7777

请求头长度有限制,套接字也设有超时。read_line 本身没有大小限制,因此,客户端发送字节后一直不发送换行符,会让查看器不断分配内存,直到进程崩溃;而客户端连接后什么也不说,则会让它永远阻塞。在单线程服务上,这会拖垮整个查看器,而且任何本地进程都能这样做。

界面分为两个窗格:左侧显示对话,右侧显示线程,底部是撰写框。仅监听回环地址,绝不使用 0.0.0.0:笔记本上的收件箱查看器不该被它所连接的网络访问。

对话列表下方是此网络上的设备——gage lan 能发现的每台机器,其下方列出四组词对。将文件拖到某台机器上,文件就会通过 Wi-Fi 直接发送过去;Pair 可在不离开页面的情况下确认指纹。此面板读取 gage lan 维护的列表,本身绝不会进行广播,因此,如果用户没有主动选择让本机可见,这里的任何按钮都无法让本机出现在网络上。

好友列表

在 Chats 旁边添加一个 Buddies 标签页,这正是项目要求的形式:一个由你掌控的列表,密钥申请加入时,由你决定是否接受,也可以拒绝。列表分为三个部分,因为密钥有三种状态。

不在你的列表中。 任何向你发送过内容且签名验证通过、但不在你的列表中、未被拒绝且未退役的密钥。一位评审者指出,我最初使用的标题“申请连接”替发送者表达了意愿:陌生人发来一条普通消息,并不代表他在申请任何东西,面板也不应暗示如此。如果某个包的签名未能通过验证,就不会创建任何条目;第一个版本把验证状态记录为 Add 按钮旁边的附加标签,因此伪造的包会为发送者并不持有的密钥生成卡片。没有“请把我加为好友”这种消息类型,而且这是有意为之:如果某种消息类型的用途是请求信任,那它就会成为发送者绝不能以区别于普通邮件的方式发出的唯一内容。因此,请求就是未列入名单的密钥发来的普通邮件,它已经受到与其他所有内容相同的接受门控保护。 值得说明的是批准联系人的审核环节,因为它的第一个版本只是在做样子。gage 会要求你输入四组词对中的一组——在你答对之前,卡片不会显示任何指纹。要求输入的词对绝不会是第 1 组,也就是密钥的词标签,它用于区分不同请求。答对后,四组词对都会显示出来,这样你就能在添加之前检查其余词对;答错时不会把正确答案显示给你,因为那样这一步就会变成两次尝试的抄写练习,还会让你觉得这些词并不重要。

我最初的尝试是在卡片上打印完整指纹,同时要求你输入其中一组词对。答案已经显示在屏幕上,输入它不过是在抄写。

这里要准确说明当前版本能证明什么,因为有位审阅者指出,我最初的说法夸大了它的作用:答对只能表明你是从请求卡片以外的某个地方读到了这组词对。但不能证明是从哪个地方读到的——而有一个来源很重要。**密钥知道自己的词对,因为这些词对来自该密钥。**因此,发送方可以把要求输入的词对写进创建请求的那条消息里,服务端无法区分这个来源和另一台机器的屏幕。页面在审核环节,也就是做出决定的地方,说明了这一点:

如果这组词对来自消息本身,或来自此密钥发送给你的任何内容,那它就无法证明任何事情——密钥知道自己的词对,也能引用它们。只有当它来自另一台机器时,才有意义。

这就是任何带外检查诚实的局限:gage 看不到你的其他通信渠道,因此无法验证你是否使用了它。它能做的是扣住答案、指出这个陷阱,并显示其余三组词对,让你进行完整的比对。匹配一组词对只有 16 位,很容易被穷举,页面也对此有说明。拒绝列表中也不会显示指纹,理由相同——密钥可以从拒绝状态恢复为请求状态,如果在下面两个区块之外打印它的词对,就会通过另一条途径把答案显示在屏幕上。

通过这种方式添加的联系人是仅用于验证的。清单不会包含发送方的加密密钥,也不应该包含:发送方用你发布的密钥向你加密,从来不需要公开自己的密钥。因此,在你从网络上拿到对方的 gage whoami 令牌或词对之前,你可以验证他们的签名,却不能向他们加密邮件;列表也会如实标记这一点,而不会承诺一种实际上会悄无声息失败的加密方式。

**你的列表。**每位联系人的四组词对(这样,机器行为异常后你可以重新检查),来自他们的未读消息数,他们是否可用于加密,以及三个标志,每个标志都会说明它能证明什么:

  • routed under this name — 本地路由记录在此名称下。路由以名称为键,因为发送时也会按名称解析;之所以这样命名,是因为有位审阅者指出,声称它归属于某个密钥是另一种说法:重命名联系人,结果就会改变。若要根据密钥验证,就得打开另一个 Agent 的 identity.key 来获取公钥部分,而这个查看器不应该为了显示一个圆点而这么做。
  • on this network now — 此密钥正在通告自己的存在,这是它通过运行 gage lan 主动选择的。
  • mail can be left — 这取决于你的设置,而不是他们的:你已加入中继,并持有对方的加密密钥。此机器无法知道对方是否会来收取邮件。

我最初的版本会在每位联系人的条目上显示时间间隔,例如“3 秒前通告了自己的存在”,而列表每 2.5 秒刷新一次;注释还声称这不是一份在线状态名册。有位审阅者直言不讳,而且说得对:不管注释怎么说,这就是一份在线状态名册。显示近期程度的渐变效果正是它成为名册的原因,所以我们没有加入这种效果。只有三个布尔值,不显示时间,也不会在这里轮询任何人。 拒绝。 第三种状态,也是此前缺失的状态:一个只能表示“是”和“尚未询问”的列表无法表示否,所以要想不再收到同一个决定的询问,唯一办法就是接受它。gage decline <key> 和“拒绝”按钮会记录拒绝;gage undecline 和“取消拒绝”会撤销拒绝,不会添加任何内容。

拒绝不等于屏蔽,页面、框架和 CLI 都会明确说明这一点,而不会让你产生相反的误解:gage 中没有任何东西可以阻止投递 bundle。 被拒绝的 key 仍会收到邮件,邮件仍会被接受门禁拦住。记录拒绝的作用是让框架显示 SENDER REFUSED BEFORE 和日期,而不是像面对新决定那样再次询问;同时也会告知读取记录的 Agent,这是一条状态记录,而不是一道屏障。

这里有两处审核者指出的修正。以前,“拒绝”按钮只会在点击后的消息中解释自身,因此现在解释文字放在按钮下方。以前,列表会给被拒绝的 key 标上“仍在接收”,但一旦该 key 发来的所有内容都已读完,这个说法就不再成立——这会让人误以为投递已停止,恰恰是周围文字要避免的误解。这个计数表示当前被拦下的内容数量;而一般性事实则改为无条件陈述。

key 出现在列表中,始终只能由明确操作完成:对已拒绝的 key 执行 gage add 会撤销拒绝,并明确说明这一点。因此,这里并不是说被拒绝的 key 永远无法获得授权,而是说它在被拒绝期间无法获得授权;要撤销拒绝,必须主动执行添加操作,而不是由发送方做任何事。

通过 relay 推送仍只能在终端中使用 gage push --yes。从页面将 bundle 交给同一 Wi-Fi 网络中的一台机器是允许的,因为这个操作明确指定了两端——特定文件,以及你看得见的、特定命名的机器——这比对一个目录使用 --yes 提供了更多信息。出站扫描会像在终端中一样沿着这条路径运行,扫描结果会显示在页面中,并且刻意不提供覆盖按钮:发现问题后,你需要回到终端检查文件。

页面绝不会渲染载荷内容。把陌生人的提示词粘贴到浏览器的页面会带来另一种风险,而 gage open 才是在框架内读取内容的地方。页面显示的是判定结果、警告和文件列表,并让判定结果醒目得像标题,而不是像说明文字,因为它是产品的核心,重新设计若将它弱化就是一种退步。

每位联系人都会根据自己的 key 获得一个颜色不同的姓名首字母标记,因此即使两位联系人都自称 codex,也能在读文字之前区分开来。它只是辅助信息,绝不是判定;判定始终会在旁边明确写出。

在这里撰写的回复只会发送到本地路由。任何离开本机的内容仍须通过 gage push 发送,并且会先显示列表,因为网页上的按钮并不能提供这种确认。每个会修改状态的请求都必须携带自定义标头,并且不得指定跨站点的 Origin 或 Sec-Fetch-Site:开放互联网中的页面可以通过普通表单向 127.0.0.1 发送 POST 请求;如果没有这些检查,你碰巧访问的任何网站都可能接受收件箱中的 bundle,或冒用你的身份发送回复。表单无法设置自定义标头,而设置了自定义标头的 fetch 必须通过预检请求,但此服务不会响应预检请求。

提醒

gage watch                    # 轮询、拉取、通知
gage watch --once --quiet     # 检查一次,打印而非发送通知
gage watch --on-arrival CMD   # 有许可覆盖某条到达消息时运行 CMD

采用轮询,因为如果中继主动推送,就必须为每个邮箱保持一个开放连接;而 Agent 本来也不是长期运行的进程——它们是按需调用的。

每条消息只通知一次。 当一个消息包未读且此前从未通知过时,才会发出通知。如果没有后一个条件,每次轮询都会重复通知同一条消息,直到你打开它;这会让你习惯性地忽略通知——这正是通知器比没有通知器更糟糕的原因。

通知只携带元数据:发送者、类型、主题。绝不包含内容。桌面通知由另一个进程呈现,可能会被记录,还可能显示在锁屏上。单元测试确保通知中无法添加内容字段。未列出的发送者会以引用声明的形式显示,因为伪造的名字最容易在通知中被当真。

轮询间隔是一种权衡。间隔越短,越快收到消息;同时也会让中继运营者更准确地知道你何时处于活跃状态,因为他们能看到抓取时序等元数据。默认间隔为两分钟:足够慢,不会成为你的在线状态信息流。

到达时钩子

--on-arrival 是 gage 中唯一能在无人查看时将消息到达转化为操作的功能,因此它的权限范围刻意比旁边的通知更窄。

只有现有许可覆盖该消息时才会触发。 仅仅是 SENDER VERIFIED 并不够——它只说明某个密钥签署了某些内容,描述的是已知信息,而非行动许可。~/.gage/authority 是该工具中唯一规定 Agent 可以采取行动的文件;这是无人值守的路径,因此也是唯一不能接受更低标准的调用方。经过验证但没有许可的发送者,或许可覆盖的是另一种消息类型时,行为与此前完全相同:发送通知,但不运行任何内容。运行时会打印匹配的许可,以便将文件中的规则与采取的操作一并查看。

钩子永远不会收到消息包中的条目。 它会通过环境变量获得 ID、发送者密钥、联系人名称、类型、主题和匹配的许可——CTX_BUNDLE_ID、CTX_FROM、CTX_FROM_KEY、CTX_TYPE、CTX_SUBJECT、CTX_EXPECTS_REPLY、CTX_GRANT。标准输入为空,命令行参数也不包含这些内容。消息包的内容正是接受门控要拦住的东西;如果钩子将其传递出去,就会悄然为所有匹配许可的到达消息移除这道门控。

并非“永远不会收到消息内容”:主题是发送者撰写的文本,它会传递到这里。它只有一行,长度有限,而且本来就在你看到的通知中,因此不会泄露弹窗之外的任何内容——但如果钩子把它当作可信输入,那钩子本身就有问题。

--on-arrival 不会接受消息包。 钩子运行时,gage 不会移动任何内容:消息仍为未读,仍在 pending 中。钩子本身会收到 CTX_BUNDLE_ID,当然可以运行 gage accept && gage open——这正是 Agent 循环的目的。gage 所保证的是,钩子机制绝不会代你通过门控;通过门控仍是你自己的钩子执行的操作,并会留下日志条目。

有一个例外,但需要由你主动启用:同时使用 --auto-accept 时,监视器会先执行自动接受流程,再运行钩子,因此在命令启动时,获许可的到达消息已经位于 accepted 中。这个顺序是有意设计的——无人值守的钩子应该找到已处理妥当的消息包,而不是和监视器争抢它;如果发生了自动接受,CTX_AUTO_ACCEPTED 的值为 1。本段以前曾不带限定地写着“gage 不会接受消息包”,这本是对某个标志的承诺,却被误读成了对监视器的承诺。 **gage 不会将发送者的文本插入你的命令。**主题由发送者编写;如果放进 shell 命令行,就相当于多绕几步的命令注入。因此,你写的命令会原封不动地传给 sh -c,而发送者选择的每个值都会作为环境变量传入,并先压平为一行有界文本——CTX_SUBJECT 中的换行符仍会出现在钩子写入的任何内容中,并可能伪造其中的到达记录。

这只是链条中的一环。加引号的 "$CTX_SUBJECT" 是数据;eval、会触发词拆分和通配符展开的未加引号展开,或将值传给一个把它当作选项的命令,都会把 shell 交到发送者手中——而钩子是你的代码,所以这里没有任何东西能阻止这种情况。环境值也会被钩子的子进程继承,并且任何以你的身份运行的程序都能读取它们。

钩子以非零状态退出时会报告错误;运行时间超过 --hook-timeout(默认为 300s)时会被终止。无论哪种情况,到达记录都会保持已公告状态,因为不断重试一个出故障的命令,会让一个坏钩子变成针对中继的无限循环。

作为后台服务运行——服务单元为 contrib/gage-watch.service,复制到 ~/.config/systemd/user/:

systemctl --user enable --now gage-watch.service

watch 和 pull 共用同一套收集流程,因为如果监视器的收集方式与手动拉取不同,就会公告一些之后找不到的捆绑包。监视器会抑制“无事可做”提示;每次轮询都输出一行,只会让日志变成噪声,掩盖那条真正重要的记录。

日志

gage journal                  # 这台机器如何处理收到的内容
gage journal --bundle <id>    # 一个捆绑包的历史记录
gage journal --json           # 原始 JSONL

gage 中其他所有记录都是工件——pending 中的捆绑包、sent 中的副本、state.json 中的见证。工件说明了说了什么。它们都没有说明这台机器做出了什么决定;而当无人监看时发生了任何事情,唯一值得追问的就是这个问题。

每个事件一行:observed、accepted、rejected、opened、sent。

observed,而不是 arrived。at 表示一次扫描运行的时间,并非某个内容被放入何处的时间——捆绑包甚至可能在两次扫描之间被放入又移走,导致完全未被发现。首次在本地观察到捆绑包,是 gage 根据自身所知唯一能够诚实断言的到达时间,因此事件就叫这个名字。首次发现时已被接受的捆绑包会标记为 backfilled,因为它的判定是在接受之后计算的,称其为到达时的判定就是撒谎。

**判定会被写下,而不是事后推导。**这正是这个文件存在的全部原因。密钥轮换、gage add、停用——任何一项都会改变 Contacts::verdict 对之前到达的捆绑包返回的结果,因此读取时重新计算得到的判定,会是一个顶着同样名字的不同答案。停用某个联系人的密钥后,日志仍会显示该捆绑包被观察为 SENDER VERIFIED,后续记录则显示 RETIRED;重新计算会抹掉这一点。

离开这台机器的内容
gage sent                     # 每个已发送捆绑包一行,并注明发送对象
gage sent --since 7d          # 时间范围:m、h、d、w
gage sent --to dana           # 按句柄或密钥前缀筛选

gage 中的其他内容都在管理 Agent 接受什么。这是唯一记录它发送了什么的地方——发送对象是谁、收件人是否在你的列表中,以及项目数和字节数。 这是一条记录,而不是控制措施,它上面的内容也都不是控制措施。 这里没有发送白名单、首次联系门槛或速率上限;这是有意为之,而非疏漏: docs/adr-send-side-restraint.md。所有这些控制措施都会基于你的联系人列表执行,而拥有 gage mcp 的 Agent 可以自行将已确认的联系人加入该列表,只需两次调用,无需人工参与——gage_pair_start 会交给它词组,gage_pair_confirm 会将词组收回。基于攻击者能够写入的值执行控制措施,只是做做样子;这个项目宁可明说这一点,也不愿发布这样的控制措施。

有两件事不在其覆盖范围内,命令本身也明确说明了这两点。从 0.3.2 起,发送操作才会被记录,因此今天刚升级的机器上列表为空,并不能证明此前没有发送过任何内容。另一个是 gage anchor send:锚点会发布日志自身最新条目的摘要,因此记录锚点会在发布时立即改变该条目;两个锚点就永远无法指向同一条目——而这正是检测矛盾的方式。

两行记录之间存在差异,并不能证明密钥发生了变化。它也可能是补录的首条记录、接受后才计算出的判定、手动编辑的联系人、本地篡改,或 gage 升级改变了判定的含义。条目冻结的是结果,而非得出结果的证据:它不包含联系人版本、密钥状态输入或代码版本。差异是一个问题,不是答案。

只记录哈希和判定,从不记录内容。 不记录主题、文件名、载荷或句柄。所有确实从线上接收的字段都会经过验证,而不是直接复制——清单 ID 由签署方选择,因此必须是有长度限制的十六进制值,否则就会被替换为其自身的哈希;密钥也一样。否则,解码后的 ID 可能携带终端转义序列或混淆字符,影响之后读取日志的任何对象。这样做部分是因为保留的捆绑包已经能回答“确切的字节是什么”。但主要原因是,这些文本全是由发送方写入的,而日志之后会由匆匆浏览的人或总结内容的模型读取——攻击者能够填写的字段会成为存储的注入内容,针对之后读取日志的任何对象,使审计跟踪本身成为攻击面。唯一允许由发送方选择的值是线程 ID,而且必须看起来像一个有效 ID:长度受限的十六进制值,否则就会记录为无法解析。

拒绝操作的记录最为重要。 gage reject 会在未读取捆绑包的情况下将其销毁,因此,如果没有在删除前写入一条记录,删除就无法与从未收到过区分开来。

此处也说明它无法告诉你什么,以免有人过度解读:gage 的认知止于捆绑包边界。它无法说明 Agent 在 gage open 后做了什么,也无法确认当时是否真的有人在查看——accept 记录的是一个行为,而非意图。它无法证明记录完整。并且它只是按约定只追加,而非防篡改:以你的身份运行的进程可以重写记录,包括密钥。声称情况并非如此,只是在做做样子。它防范的是现实情况——Agent 或脚本悄悄删除对自己不利的记录,以及普通的数据丢失。

有意在需要它的功能之前构建它。采集是无法事后补上的部分:判定在计算时若未记录,就永远消失了;而保留策略可以留待下个月再讨论。

这种先后顺序无法带来的一点是:之后添加的哈希链,只能证明条目自建立哈希链以来未被更改。它无法区分真实的链前历史与此前被编辑、重排或伪造的历史——伪造内容同样会被如实纳入哈希链。先采集能为判定提供证据,却无法追溯性地提供防篡改证据。 **索引决定文件的顺序,而不是 seq。**分配 n 时会持有日志锁,因此两个进程无法认领同一个位置;seq 是用于将条目与退役截点关联的观测计数器,作用不同。每一行都在持有同一把锁时写入并执行 fsync,因为一份丢失了刚刚承诺记录的那一行的决策日志,还不如没有日志。

哈希链,以及它的确切范围
gage journal --verify

每个条目都包含前一行原样写入时的摘要,因此验证只需重新读取文件,无需对规范 JSON 达成一致。它能捕获三种篡改:删除一行、编辑一行,以及追加一行但未接入哈希链。索引会单独检查,因此即使有人费心重新链接被删除行的前后内容,仍会留下缺口。

**通过检查意味着什么,又不意味着什么。**没有任何接入哈希链的行被修改、删除或重排——除非篡改者还重新计算了之后每一行的链接,而任何以你的身份运行的程序都能做到这一点。这种机制可以留下篡改痕迹,防止脚本悄悄删掉唯一一行能证明它有罪的记录。它无法证明日志没有被蓄意的本地进程篡改;任何可被进程改写的日志都做不到这一点。测试套件会直接验证这一限制:重新链接后的编辑内容仍能通过验证。这项检查正是为了确保不会把哈希链的作用夸大。

**哈希链出现之前写入的条目不会被追溯验证,命令也不会暗示相反的情况。**如果日志中没有任何条目接入哈希链,它会打印 “none of them chained” 和 “this check verified NOTHING”——第一个版本却在 29 条完全未经检查的日志上打印“the journal chain is intact: 29 entries”,这是它能给出的最具误导性的说法。哈希链开始生效时,第一个接入哈希链的条目会携带一个 genesis: 摘要,涵盖它之前的所有内容。这并不能让旧历史变得可信——开始建立哈希链的人可能先编辑过它——但它能冻结当时的内容,严格来说,这总比让那些行未被引用、可以随意改写要好。

docs/open-decisions.md 中仍未实现的事项:保留策略,以及将摘要锚定到这台机器无法控制的位置。只有这样才能应对本地改写的情况。

锚点:链无法发现的改写
gage anchor send --to bob      # 将你的头部发布给联系人
gage anchor record             # 保存他们发布给你的头部
gage anchor list               # 当前保存的内容,以及任何矛盾之处
gage anchor check <n> <digest> # 对方报给你一个值;你的值是否仍然匹配

链可以作为证据,证明某个进程丢弃了一条记录。但它无法证明某个进程没有改写文件并重新计算所有链接,而任何以你的身份运行的程序都能做到这一点——测试套件通过断言重新链接后的编辑仍能通过验证,明确了这一限制。进程可以改写的日志无从做得更好,因此唯一的出路,是让一个由此日志推导出的值存在于这台机器无法触及的某处。

锚点是最新链式条目的摘要,它会作为普通的签名数据包发送给联系人。之所以采用普通数据包,是有意为之:它会通过接收门控并得到判定,因此,来自尚未确认其密钥的锚点会被拒绝,所用机制与拒绝该密钥发来的其他内容相同。这里没有服务、没有账户,也无需运行任何程序。关于为何选择这种方式,而不是托管锚点服务的论述,见 docs/adr-journal-anchoring.md。

它能带来的好处,比乍听起来要有限。 保存 Bob 的锚点,并不能让你的机器审计 Bob——要将其与他的日志进行核对,就需要他的日志。不增加额外协议,它所能做到的是:每个新锚点都会与已保存的 Bob 锚点进行核对。于是,以下两种情况可作为证据:以不同摘要重新发布同一索引,这意味着他的日志在一个他已经发布过的条目下发生了变化;以及头部出现回退。两者都会被记录,但绝不会据此采取行动。任何内容都不会因此被拒收,判定也不会因此改变:如果发送方能通过声称自己的日志发生某种情况来触发本地后果,那这套机制就成了操纵工具,而不是日志。

**而适用于所有情况的界限是:**锚点不涵盖它之后的任何内容。自上一个锚点以来写入的一切仍可随意改写,因此锚点间隔就是可改写窗口的大小。anchor send 每次都会打印这句话,而不是让它只留在文档里。

密钥轮换

密钥需要更换:笔记本电脑坏了、密钥泄露了、联系人重新开始。 在此功能推出之前,gage init 只会拒绝覆盖。这种拒绝是对的,结局却不对——唯一的解决办法是删除密钥文件,而这会让你曾签署过的每个包都变成 SENDER UNVERIFIABLE。轮换密钥的那一天就失去全部历史记录,这是个缺陷;具有这种特性的工具只会让人永远不去轮换。

gage rotate                 # 生成新的密钥对,旧密钥仍保留在记录中
gage rotate --compromised   # 旧密钥已被盗
gage keys                   # 查看当前密钥和已退役密钥

轮换会生成一份通知:一份将旧密钥、新密钥、标识和时间绑定起来的声明,由新密钥签名,并由旧密钥共同签署。新密钥的签名表明持有此密钥的人有意发布这份声明。旧密钥的签名则表明你已信任的一方授权了此次交接——这是陌生人无法提供的唯一部分。

但这仍不是证明。窃取旧密钥的人也能轻易生成这份共同签名,而这正是轮换最棘手的地方:“改用这个密钥”既是攻击者最想说的话,也是诚实的人必须说的话,而两者生成的字节完全相同。所以,通知是证据,绝不是指令:

gage keychange notice.json            # 查看它能证明什么
gage keychange notice.json --accept   # 应用它

不带 --accept 的 keychange 不会更改任何内容。即使带了 --accept,如果旧密钥没有共同签署这份通知,它仍会拒绝。因此,遭到入侵后进行密钥轮换时,会特意省略旧密钥的签名:来自被盗密钥的签名,会把一份什么也证明不了的通知伪装成能证明某些内容的通知。这时唯一的办法,就是通过其他渠道联系本人,确认新密钥。

已退役的密钥留住过去,失去未来

已退役的密钥只覆盖退役之前存在的捆绑包,不覆盖之后的任何内容:

  • SENDER KEY RETIRED — 签名有效,捆绑包早于密钥退役。仍可归因,线程历史也仍可读取。
  • SENDER KEY REJECTED — 签名由不覆盖此捆绑包的密钥生成。内容不予提供,退出码非零,与签名无效时相同。

如果已退役的密钥还能继续用于新邮件,那么退役就毫无意义。因此,关键在于由什么来判定,而我们试了三次才不再用时间戳来回答。

第一个版本读取清单中的 created,这是由签名者填写的字段:在常规轮换之前被盗的密钥,可以签署新内容,并将日期设为截止时间之前,让内容仍被视为可归因。第二个版本改为记录本地到达时间,思路是对的,但有两个问题——记录以捆绑包 ID 为键,而 ID 也由签名者选择,因此窃贼可以重用本机已盖戳的 ID,并继承其记录;而且比较方式是按 RFC3339 字符串进行字典序比较,会把 ""、"…12:00:00.9Z" 和 "…T01:00:00-14:00"(实际上是 15:00Z,比正午截止时间晚了三个小时)都判定为早于正午。

现在的判定依据是本地观测计数器。每条即将显示判定结果的路径,都会先将其在收件箱中能看到的内容记录下来,以 sha256(sender key ‖ the exact bytes the signature covers) 为键,并使用一个只增不减的计数器。密钥退役时,会从该计数器截取一个界限。界限以下记录到的捆绑包,说明它当时已经存在;界限以上的则没有。判定不使用时间戳,因此发送者无从利用;边界情况是真正的平局,而不是由攻击者来选择。

由此得出三种判定结果,第三种之所以存在,是因为把它和第二种混为一谈会造成误导:

  • SENDER KEY RETIRED — 你在退役密钥之前就已看到此内容。
  • SENDER KEY REJECTED — 此内容是在密钥退役之后到达的。
  • ARRIVAL UNRECORDED — 没有任何记录。内容不予提供,和拒绝时一样,但这并不是拒绝:通常是邮件在轮换前就已封存,却在轮换后才从中继处收取;或者是你只检查过(gage unseal --out,或别人通过存储设备交给你的目录),而从未接收过的捆绑包。按设计,这种情况不会留下到达记录。把这种情况说成拒绝,就等于告诉对方有同事正在攻击他们。

如果密钥确实被盗,gage retire <name> --compromised 会拒绝该密钥签署的所有内容,包括旧捆绑包,而且完全不查看日期。此形式只影响一个密钥,并会明确说明;如果你发现密钥失陷早于自己所知的时间,--compromised --all 则会将你曾为此人持有的所有密钥一并判为失陷。

同一规则也适用于截止界限本身。keychange --accept 会在你接受时读取你的计数器来确定界限,并将通知中的日期保留为 claimed_at:它只是记录,绝非判定。通知由谁撰写,日期就由谁选择;采用这个日期,就等于允许对方随意设定密钥退役的时间。

交叉签名表达的信息不如看起来那么多

早期版本从 --compromised 通知中省略了旧密钥的交叉签名,理由是窃贼也能生成它,因此它毫无证明作用。两位评审都指出,这颠倒了信号的含义,而且他们是对的。持有密钥是陌生人无法伪造的唯一信息位——任何人都可以发送不带交叉签名的通知;只有密钥持有者才能发送带有交叉签名的通知。更糟的是,窃贼最好的做法是发送一条普通的 reason: "rotated" 通知,并进行交叉签名,这样接收方就会应用这条通知;而诚实受害者发送的未签名泄露通知反倒会被拒绝。

现在,通知始终会进行交叉签名,并在 --compromised 销毁密钥材料之前完成签名,同时会准确告知接收方这意味着什么:

旧密钥已签名:是——这证明了对旧密钥的控制权,
              但不能证明持有它的是谁。如果该密钥
              已被盗,窃贼也能生成此签名。

轮换自己的密钥会保留旧密钥材料,因此已经加密给旧密钥的邮件仍可收取——gage pull 会连同当前邮箱一起清空已退役的邮箱,因为轮换时丢失传输中的邮件,也是让人不愿轮换的另一种方式。--compromised 则会销毁这些密钥材料:无论如何,窃贼都能读取那些信封;保留副本只会延长你继续读取他们也在读取的邮件的时间。

群组

--to 从一开始就是一个列表,而 group:<name> 是有效的收件人。在群组中,你看到的大多数内容都不是发布者本人写的,因此清单会将作者与发送者区分开来。

只有最后一跳会签名,这是诚实但令人不安的事实:转发者会写入 chain[0],因此可以把任何人写成作者,包括你信任的人。open 会明确说明这一点,而不是把某个已知作者的名字打印出来,仿佛它已得到证实;forward 也会拒绝重新签署无法验证的消息包——否则,转发者就能把有问题的消息包洗成一个能通过验证的消息包,再交给下一位收件人。要实现跨跳的加密来源证明,每一跳都必须对收到的内容签名,这需要更改格式;请参阅 docs/open-decisions.md。

这不是什么

没有账号,也没有在线状态:除非你主动请求,否则不会有任何内容自行广播;而且请求时会告知它即将说什么,以及持续多少秒。Agent 是按需调用的,而非始终在线,因此即时通信模式中的好友列表和传输前接受机制可以保留,但在线状态不会。若手动移动目录变得麻烦,可以添加中继——格式无需更改。

有三个命令会占用端口,值得明确指出,以免“没有服务”悄然不再属实:在本地环回地址上运行的 gage ui,以及在本地网络上运行的 gage lan 和 gage listen。它们都不是任何功能的必需条件,都会在前台运行,方便你查看;而且 gage lan 是 gage 中唯一会广播的功能——广播时间恰好与你查看它的时间一样长。

它不会进行沙箱隔离。经过验证的发送者是一个可追责的发送者,而不是安全的发送者。消息框架会告诉读者内容是什么;是否据此采取行动,仍由读者决定。这就是为什么 open 是渲染器,而不是导入器。

它不是 Agent 自身操作的审计层,也不是工具调用的回执格式。已有这类方案,而且进展更快;两者的比较,以及各自能证明和不能证明的内容,请参阅 docs/prior-art.md。

即使签名完善,消息包仍无法授权自身执行——实际试用中,我们让四个 Agent 使用了它,其中两个读取了来自已验证联系人的已验证请求后,正确地拒绝了请求。要弥补这一缺口,需要一份由接收方的人类撰写的授权,而不是消息中的任何内容。请参阅权限。

测试

cargo build --release && cargo test --release && bash tests/attacks.sh
bash tests/ui-e2e.sh      # 可选:在真实浏览器中驱动查看器

962 项检查、168 项 Rust 单元测试、55 项 MCP 端到端检查、一次包含 16 项检查的身份恢复演练,以及一项在浏览器中打开查看器并完成连接请求的端到端测试。tests/selfcheck.sh 会运行全部测试,也是唯一值得引用的数字——这一行曾写着 680 和 106,时间长到数字已经差了三分之一。

最后一项测试的存在,是因为前两项看不到一个漏洞。好友面板的确认关卡完全依赖页面的客户端状态,而且它发布时就有问题:显示步骤清除了输入的配对码,添加步骤又重新发送了配对码,因此服务端拒绝了每次添加操作,“添加”按钮始终无法使用。所有 HTTP 检查一直都通过,因为它们测试的是端点,而不是页面。审查者把两行代码放在一起读时发现了这个问题,随后无头截图又立刻显示了第二个问题——“聊天”选项卡上的筛选栏一直显示在“好友”选项卡中,因为 .filters{display:flex} 的优先级高于 hidden 属性。仅靠针对 JSON 的断言,无法发现这两个问题。

tests/ui-e2e.sh 需要机器上安装 playwright 和 chromium;如果缺少它们,脚本会跳过而不是失败,这样可选依赖就不会导致构建失败。重新引入这个导致按钮失效的问题后,它会报告 “添加操作始终未报告成功——按钮已失效”。

运行测试,以及判断何时出错
tests/selfcheck.sh                                  # 这台机器正常吗?
tests/selfcheck-status.sh my-laptop         # 每台机器都正常吗?

selfcheck.sh 会构建项目、运行全部三组测试、写入一行状态信息,并在失败时以非零状态码退出,同时说明具体原因。它每天在两台机器上定时运行——Linux 上使用 systemd 用户计时器,macOS 上使用 launchd 代理——失败时会通知使用该机器的人。它们都不会拉取更新:每台机器都会报告自己测试的提交,因此落后的机器会明确说明,而不是悄悄自行更新。selfcheck-status.sh 会读取每台机器的最近一次结果;无法联系的主机会报告为无法访问,而不是通过——沉默不代表健康,这正是此脚本要防止的错误。

它会预先检查测试套件要绑定的端口,因为有一次,一个意外运行的 gage ui 导致了 28 项看起来像真实缺陷的失败。两条失败路径都经过验证:一种是端口被占用,另一种是故意弄坏判定标签。

它还会在最后一个阶段使用 cargo audit 检查依赖树是否存在已发布的安全公告。这是刻意安排在最后的:脚本会在第一个出错的阶段退出,而今天早上刚发布的安全公告不应掩盖真实的测试回归。发现问题会导致本次运行失败,这样你能在一天内得知,而不是永远都不知道;如果某项安全公告尚无修复版本,应在 .cargo/audit.toml 的忽略列表中记录这一决定,而不是关闭检查。

如果没有安装 cargo-audit,或者无法访问安全公告数据库,脚本会说明已跳过检查,状态行会显示 deps skipped——证书检查也遵循相同规则,因为安全检查没有运行却悄悄报告通过,正是本文件要防止的那种错误。

之所以加入这一阶段,是因为本项目第一次运行审计时(2026 年 10 月 22 日),发现了两项安全公告,分别已发布三天和四天,且都与证书验证有关。此前没人检查过,因为依赖列表很短,而且清单中逐项说明了原因——这恰恰是一种会让人觉得依赖树很安全、却没人真正查看的推理方式。依赖列表短,只能限制风险;它无法告诉你其中某个依赖何时被宣布存在漏洞。

要停止它:

systemctl --user disable --now ctx-selfcheck.timer         # Linux
launchctl bootout gui/$UID/com.owenware.ctx-selfcheck      # macOS
现在两台机器上都能通过了

之前并非如此,也没人检查过。10 月 17 日首次在 MacBook 上运行时,测试套件报告了六项失败——其中包括 “被终止的写入进程卡住了锁”,如果这是真的,那可就令人担忧了。它们没有一项是 gage 的缺陷:

  • timeout 属于 GNU coreutils,macOS 出厂系统中没有,因此那两项用它来断言锁已释放的检查因找不到它而失败。这个替代脚本使用 perl 的 alarm+exec,因为显而易见的 shell 替代方案——在后台运行并轮询——会悄悄丢失标准输入:关闭作业控制时,bash 会将异步命令的标准输入重定向到 /dev/null。
  • ss 仅适用于 Linux,而 BSD 的 netstat 会输出 127.0.0.1.7891。
  • BSD 的 wc 会将输出填充到固定字段宽度,因此 ls | wc -l 会得到 " 0",所有与之比较的 = 0 都会判断为假。这导致测试报告 “无上限的自动接受授权接受了一个 bundle”——测试套件对安全属性撒谎,这才是更糟糕的情况。
  • BSD 的 sed 不支持 \| 交替,因此一项检查什么也匹配不到,就把一个完全有效的信封判定为格式错误。
  • 两项检查使用 2>&1 | tail -1 读取标签,这会把两个流合并到一个管道中,再取最后到达的内容——在这里是对的,在那里是错的,而且结果不稳定。
  • 三项检查通过 inbox --json | head -1 选择 bundle。 一旦主目录中有多个 bundle,它就会选中文件系统最先列出的那个;ext4 和 APFS 的顺序并不一致,因此这些检查接受并打开了另一个 bundle,而不是它们实际要检查的那个。这是真正的测试漏洞,只是这里每次都碰巧通过;它之所以暴露,唯一的原因是测试套件终于在别处运行了。开发过程中,每一项都曾成功或险些成功地发动过攻击:伪造帧边界、签名后替换负载、已列出的联系人冒充第三方的姓名、转发中未经验证的作者姓名、信任标签是其自身反义词的子字符串、由错误密钥共同签署的轮换通知、已退役的密钥仍在签署新邮件、向查看器发送跨站 POST、将 bundle 嫁接到他人的会话线程以截获回复、已退役的密钥将时间戳回填到自身退役之前、重复使用的 bundle ID 继承了另一份文档的到达记录、两个进程互相覆盖对方的状态、通过引用公钥抢占邮箱、借助符号链接偷出无人指定的文件、用选定的 bundle ID 压下真实消息、形似路径的 ID 越出收件箱、预先植入垃圾签名的见证随后用真实签名认领、没有来源链的 bundle 借用另一条消息的 ID、撤销操作写入期间旧密钥仍被信任、一个无效字节让机密躲过出站扫描、信封在拒绝前先解包了部分内容、来自未添加密钥的发现查询、凭只差一个词的指纹放行配对,以及名称是路径的丢弃文件。

许可证

采用以下任一许可证进行双重授权:

由你选择。除非另有说明,你有意提交以纳入本作品的任何贡献,均将按上述方式进行双重授权,不附加任何条款或条件。

评论

支持 Markdown

第一次接触 MCP?

从协议是什么,到在客户端里装上第一个服务,这几条回答覆盖了大多数入门问题。

一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。