Block 开源 buzz:多 Agent 平台的 Git 仓库托管链路
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
Block 开源的多 Agent 通信平台 buzz 把 Git 仓库也搬进了它的消息网络,这件事真正的技术含量不在「去中心化」四个字上,而在于它把「谁有权改这个 ref」和「这次改动到底算不算成功」拆成了两套彼此独立、都不依赖本地磁盘的机制。 凭据是一个独立的小程序,签名是另一个独立的小程序,仓库本体则完全躺在对象存储里,服务端进程本身不持有任何权威状态。这条链路值得拆开看,因为它换掉的正是绝大多数团队默认「反正有 GitHub」而从没想过的那几个假设。
先把底座讲清楚。这个 buzz 不是什么热度指标或者蜂鸣器,是 Block, Inc. 以 Apache-2.0 开源(LICENSE 里写的是 Copyright 2026 Block, Inc.)的一套 Rust 项目,仓库 README 给自己的定位是「一个人和 Agent 一起干活的工作区,跑在你自己拥有的中继上」。它建在 Nostr 协议之上。在 Nostr 里,一条消息叫「事件」(event),本质是一份带 secp256k1 签名的 JSON 记录,任何拿到它的人都能独立验签,不需要问任何服务器「这条是不是真的」;事件有一个整数字段 kind 表示类型;「中继」(relay)就是接收、存储、转发这些事件的服务器,它不发身份,只搬运;协议本身的扩展提案按编号叫 NIP,下文出现的 NIP-98、NIP-34 就是这类编号,buzz 自己写的那些则放在 docs/nips/ 下用字母编号(比如 NIP-GS)。所谓「密钥自持」,是指你的身份就是那把私钥本身,没有账号密码找回这一说 —— 这一点在下文的代价一节里会反复咬人。buzz 的中继实现在 crates/buzz-relay,同一个进程顺带就是 Git 服务端。
站内已有三篇相邻的文章:AI 生成代码的安全审计讲的是怎么审查产出的代码内容,opencode 的存储与同步讲的是会话与配置怎么在多端落地,Agent 工作区隔离讲的是怎么把 Agent 关在一个不会伤到主仓的目录里。本篇跟它们不重叠:这里讲的是仓库这个共享资源本身该由谁托管、写入怎么定序、身份怎么绑到每一次提交上。
一、这条链路要解决什么
把 Agent 拉进研发流程之后,有三件事同时变得别扭。
第一件是凭据。你不可能给每个 Agent 发一个 PAT(个人访问令牌)然后指望它别泄露 —— 令牌是长期有效的静态口令,一旦落进日志或者被别的进程读走,撤销之前它一直有效。
第二件是归属。当仓库里一半的提交是 Agent 打的,git log 里的 author 字段就只是一个可以随便填的字符串。你想知道「这个提交到底是不是那个 Agent 打的」,需要的是密码学证据,不是一行文本。
第三件是托管形态。buzz 的中继要能多实例横向扩展,就不能让某个实例的本地磁盘成为仓库的权威副本 —— 一旦有权威磁盘状态,你就得处理实例间协调,而这恰恰是这套设计想避开的依赖。
buzz 给这三件事各配了一块独立组件,边界切得很干净:
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
git-credential-nostr | 把 git 的一次 401 换成一枚 NIP-98 签名事件当凭据 | crates/git-credential-nostr/ | 第一次 clone / push、或认证一直失败时 |
git-sign-nostr | 用同一把 Nostr 私钥给 commit 和 tag 出 BIP-340 Schnorr 签名 | crates/git-sign-nostr/ | 想让提交本身可验签、或 git verify-commit 报错时 |
| 对象存储 Git 协议 | manifest 指针 + 内容寻址 pack + 条件写 CAS | crates/buzz-relay/src/api/git/(store.rs、manifest.rs、cas_publish.rs、hydrate.rs) | 并发 push 拿到冲突、或换对象存储后端时 |
| 推送权限模型 | ref 模式匹配、角色求值、保护规则合并 | crates/buzz-core/src/git_perms.rs | push 被拒、要配 buzz-protect 时 |
| pre-receive 回调 | HMAC 签名的策略回调,任何异常都判拒 | crates/buzz-relay/src/api/git/policy.rs、hook.rs | 排查「为什么我的 push 是 403」 |
| 规范与形式化模型 | 协议规范文档、TLA+ 模型 | docs/git-on-object-storage.md、docs/spec/GitOnObjectStore.tla | 评估这套设计能不能上生产时 |
这几块能各自独立看:前两块是挂在 git 自己的插件接口上的独立可执行程序(凭据助手、签名程序),第三块整个在服务端,客户端那侧看到的仍是标准的 git HTTP 传输 —— 三者之间没有任何私有的进程间协议。
二、凭据:把一次 401 换成一枚签名
git-credential-nostr 是一个 git 凭据助手(credential helper)。它的工作流在自己的 README 里写得很直白:Buzz 的 Git 服务端返回 HTTP 401 并带上 WWW-Authenticate: Nostr realm="...", method="GET" 这样的响应头,git 就把请求详情从 stdin 喂给这个助手;助手加载你的 Nostr 私钥,构造一枚 NIP-98 定义的 kind:27235 事件,签名覆盖请求的 URL 和方法,base64 编码后从 stdout 吐回去;git 拿到后带着 Authorization: Nostr <token> 重试,服务端验签放行。
配置只有两条加一个密钥文件:
git config --global credential.helper nostr
git config --global credential.useHttpPath true
mkdir -p ~/.nostr
echo "nsec1..." > ~/.nostr/key && chmod 600 ~/.nostr/key
git config --global nostr.keyfile ~/.nostr/key
CI 场景改用环境变量 NOSTR_PRIVATE_KEY,README 说明它优先于 nostr.keyfile 且不碰文件系统。
这里有几个硬约束值得记下来,都是踩过才知道的那种。README 的 Requirements 明确写了 git 2.46+,原因是助手依赖凭据协议里的 authtype 能力 —— 源码 crates/git-credential-nostr/src/lib.rs 里能看到它解析 capability[]=authtype 并回写 authtype=Nostr,git 版本低于这个门槛的表现是「空输出、没有认证」,不报错,很难查。credential.useHttpPath 必须为 true,源码里对应的错误提示是 credential.useHttpPath must be true for NIP-98 auth;这条的意思是凭据要按路径区分,因为签名是绑在具体 URL 上的。密钥文件权限不是 0600 会直接拒绝加载(README 的 insecure permissions 一行),而且源码里有 MAX_KEYFILE_BYTES: u64 = 256 的上限,密钥文件不该是个大文件。
最后一条是时间。README 把 clock skew 列为独立故障:系统时钟偏差超过 60 秒会认证失败。这个 60 不是拍脑袋的 —— 服务端 crates/buzz-auth/src/nip98.rs 里的 TIMESTAMP_TOLERANCE_SECS 常量就是 60,事件的 created_at 必须落在服务器时间 ±60 秒内。容器里时钟漂移是常态,这条会在你完全想不到的时候炸。
对比 PAT 的收益在于:签名是一次性的、绑定到这一次请求的 URL 和方法,泄露一枚 token 只等于泄露一次请求的重放窗口,而不是一把长期钥匙。
三、签名:让提交本身带身份
git-sign-nostr 走的是 git 的可插拔签名程序接口。规范文档 docs/nips/NIP-GS.md 解释了为什么选 gpg.format=x509 而不是 openpgp:x509 格式用 -----BEGIN SIGNED MESSAGE----- 作为标记,不会跟 PGP 或 SSH 的标记撞车,尝试按 PGP 解析签名的平台不会误判;而且 x509 的验证路径不额外传参数。
配置形态是这样的(来自该 crate 的 README):
git config gpg.format x509
git config gpg.x509.program /path/to/git-sign-nostr
git config commit.gpgsign true
git config tag.gpgsign true
git config user.signingkey <hex-pubkey>
git 调用它的方式在 README 和 NIP-GS 里一致:签名时是 --status-fd=<N> -bsau <signing-key>,载荷从 stdin 进、armor 签名从 stdout 出、[GNUPG:] 状态行写到指定 fd;验证时是 --status-fd=<N> --verify <签名文件> -。密钥加载优先级是 NOSTR_PRIVATE_KEY → BUZZ_PRIVATE_KEY → git config nostr.keyfile 指向的文件,格式可以是 64 位 hex,也可以是 NIP-19 的 nsec1... bech32 形式。
NIP-GS 的动机一节把这件事的目标说得很清楚:主要用例是代替所有者提交代码的自主 Agent —— 它的 Nostr 密钥对本来就用于中继认证、频道成员身份和所有者背书,现在再多签一件事,一个身份一把钥匙贯穿所有场面。这是项目文档自己的定位表述。
签名信封里有几个约束是真的在防事:v、pk、sig、t、oa 之外的键一律拒绝;JSON 必须紧凑序列化,字符串外不许有空格、制表符、换行;重复键直接判失败。理由 NIP-GS 写了 —— 信封嵌在 git commit 对象里,任何字节变化都会改变 commit 哈希,所以必须堵死可塑性。哈希前缀 nostr:git:v1: 做域分隔,保证一枚 git 对象签名没法被搬到别的场景重放。
同时要看清它明确不管的部分。NIP-GS 的 Non-Goals 一节写着:这个规范不定义签名验证之外的信任模型,信任网、allowed-signer 列表、中继侧的提交验签都在范围之外;也不定义密钥管理、轮换和吊销。安全考量一节把后果摊开了说:一旦私钥泄露,过去所有签名依然有效,协议内没有任何办法追溯作废,攻击者可以用这个身份签任意提交,所以应用不应该只靠提交签名做授权决策。
四、仓库本体:一个指针换掉整套文件锁
这块是最有意思的。docs/git-on-object-storage.md 是一份规范文档,标题就叫「Git Refs over Object Storage: A Formal Specification」,状态标注为 draft。
核心模型只有两样东西。一是 pack 对象集合,每个 pack 用自己字节的密码学摘要当键(内容寻址),用「create-only」方式写入 —— 文档强调这不是假设对象存储自带不可变性,而是协议纪律:同一个键永不覆写,读的时候按键里的摘要校验字节,任何偏差都是可检测的而非静默的。二是一个 manifest 指针,是唯一可变的对象,里面存当前 manifest 的摘要。manifest 本身也是内容寻址的不可变对象,结构在 manifest.rs 里定义为 Manifest { version, head, refs, packs, parent },refs 是 refname 到 40 位 hex oid 的映射,packs 是构成这个仓库的全部 pack 的存储键,parent 是它取代的那份 manifest 的裸摘要。
写入路径的关键是第 7 步和第 8 步的顺序。receive-pack 收下 pack、索引、把新对象逐个 PUT 上去(内容寻址所以幂等),读当前指针拿到 ETag,校验这批 ref 更新,生成新 manifest 并 PUT,然后对指针做一次条件写:
7. result := PUT M_R (value = d_after) # CAS (A3)
If-Match: e if a pointer already exists (e from step 3)
If-None-Match: * if the repo has no pointer yet (first push / repo init)
on 412 (lost race): re-read pointer, GOTO 3 (retry) or respond non-ff
on success: the ref change is PUBLISHED
8. construct success response # ONLY after step 7 succeeds -- the FENCE
这一步的条件写(compare-and-swap,比较并交换:只有当前 ETag 等于我读到的那个才允许写入)就是唯一的写者串行化机制。文档明确写了 v1 没有任何咨询锁 —— reftable 那套 tables.list.lock 加 POSIX rename() 的组合,在这里被替换成对象存储的一次条件 PUT,锁被当成竞争时的效率优化而非正确性依赖去掉了。
实现侧把这条纪律钉进了类型系统。hydrate_for_write 一次性读指针、取回并校验父 manifest、物化工作区,返回一个携带 (ETag, digest, Manifest) 三元组的 ParentState;这个 ParentState 随 PushContext 一路带到 cas_publish,CAS 就以它携带的 ETag 为前提条件,中途绝不重读指针。「基于旧状态构建、却对新状态发布」这个经典漏洞因此在编译期被堵住了。竞争失败的 412 被映射成独立的 CasError::Conflict 变体,跟后端错误分开,这样 ? 传播不会把一次正常的竞争失败变成 500,而且处理器内不做重试 —— 输家的 receive-pack 产物是基于已被取代的父状态算出来的,重用它就破坏了不变式,只能让客户端重推。
至于「形式化验证」这个词在这里具体指什么:项目写了一份 TLA+ 模型(docs/spec/GitOnObjectStore.tla),TLA+ 是一种描述并发系统状态迁移的规范语言,配套的 TLC 模型检查器会在给定边界内穷举所有交错执行,检查你声明的不变式有没有被违反。这份模型检查了 8 条不变式,其中 Inv_NoFork(没有两份已发布的 manifest 共享同一个父 —— 分叉就等于丢更新)、Inv_RefEffectApplied(装载的推送提交的 ref 值就是它提议的值)、Inv_RefDerivedFromParent(每次装载都派生自它实际读到的指针)三条,是把「指针 CAS 有串行化」推进到「ref 更新是可线性化的」的关键。文档还老实交代了这是有界检查:模型跑在 3 个推送者、MaxManifests = 3 的约束下,不是无界证明;并且每条不变式都用一个能让它失败的变异版本证明了非空洞。
五、谁能改哪个 ref
权限模型在 crates/buzz-core/src/git_perms.rs,模块注释一句话概括:频道角色即仓库角色;kind:30617(NIP-34 定义的仓库公告事件)上的 buzz-protect 标签追加约束,且这些约束对所有人生效,包括所有者。
标签格式是 ["buzz-protect", "<ref-pattern>", "<rule>", ...]。规则字符串只有四种:push:<role>、no-force-push、no-delete、require-patch。ref 模式的语法被刻意限死了 —— 必须以 refs/ 开头,* 匹配恰好一个路径段,** 只能出现在最后一段并匹配一段或多段,不支持 ?、[...],也不支持 v* 这种半截通配(源码里 pattern_rejects_partial_glob 这个测试专门钉住了这条)。三个上限也在文件里:每仓最多 50 条规则(MAX_PROTECTION_RULES)、模式最长 256 字符(MAX_PATTERN_LENGTH)、每个模式最多 3 个通配段(MAX_WILDCARDS_PER_PATTERN)。
真正需要理解的是内置默认值和显式规则怎么合并。default_min_role 的判定是:分支或标签的创建需要 Member;分支的快进推送需要 Member;标签的移动、任何非快进更新、任何删除,都需要 Admin。而 evaluate_ref_update 里写死了一条不可绕过的规则 —— 显式的 push:<role> 永远不能弱化内置默认,取两者中更严的那个。也就是说你写了 push:member,Member 能快进推 main,但依然强推不了、删不了。测试 evaluate_push_member_cannot_weaken_destructive_defaults 就钉的这件事。多条模式同时命中时,角色取最严,no-force-push、no-delete、require-patch 三个布尔取或。require-patch 是最狠的一条,源码注释特别标了它拦截全部四种更新类型而不只是快进,被它盖住的 ref 只能走 NIP-34 的补丁评审流程改动。
这层之外还有一层。evaluate_push 是原子的:任何一个 ref 被拒,整个推送被拒。判定入口是 pre-receive 钩子回调 policy.rs,它的安全约束写在模块注释里 —— 端点只绑 127.0.0.1,回调用 HMAC 签名绑定到这次具体的推送操作,MAX_CALLBACK_AGE_SECS 为 30 秒,任何错误一律判 403(fail-closed)。Bot 角色在这一层被提升为 Member,注释给的理由是「bot 是被成员主动加进频道的;bot 是身份标记而不是权限档位」,但保护规则照样管着它。这跟最小权限设计那篇讲的是同一个思路的两种落法。
六、边界与代价
放弃了并发推送的效率。 文档自己把这条列为「v1 接受的取舍」:同仓并发推送时,每个竞争者都会各自水化工作区、各自跑一遍 receive-pack,CAS 输家的全部子进程工作被丢弃。这是竞争下浪费的 CPU 和 IO,不是正确性缺陷,但如果你的场景是十几个 Agent 高频推同一个分支,这个浪费是实打实的。
放弃了「服务端知道仓库长什么样」。 v1 架构里没有权威的本地文件系统状态,每个请求都从已发布的 manifest 现场水化一棵临时工作树,跑完 git 子进程就丢。进程内可以保留一份按摘要索引、有字节上限的 pack/index 缓存(pack_cache.rs),但缓存未命中、重启、驱逐只影响性能 —— 对象存储始终是唯一真相。这意味着延迟特性跟本地磁盘托管完全不是一回事,而文档在「范围与非目标」里明说了:这份规范只证明安全性(不会发生坏事),不证明活性和性能,一次水化能否在延迟预算内完成是经验问题,靠基准测试而不是定理。
明确不管的事情。 规范列了三条不予证明:活性与性能、git 自身的正确性(index-pack、upload-pack、receive-pack 被当作可信的上游组件,只证明这套组合喂给它们的输入是良构的),以及对象存储的三条公理本身。三条公理分别是持久写、强读后写一致性、可线性化的条件写,其中第三条是唯一吃重的后端假设。对 AWS S3 它是有文档依据的,对 MinIO、Ceph RGW 这类兼容后端它就是一个经验断言 —— 所以项目做了一个启动期的一致性探针(run_conformance_probe,在 store.rs 加 main.rs),把后端「准入」而不是「证明」。探针里承重的是并发那半:N 路并发条件写,必须恰好一个成功、其余全部被分类为前置条件失败,且最终内容是赢家的内容。探针没通过,这套设计对那个后端就是不成立的。
结构性上限是真的上限。 MAX_MANIFEST_PACKS 是 128,PACK_COMPACTION_THRESHOLD 取它的四分之三,逼近容量时一次被接受的推送会主动做全闭包压实、发布一份 pack 更少的替代 manifest。MAX_MANIFEST_REFS 是 10000。一个 ref 数量爆炸的巨型单仓,会先撞上这些结构上限,而不是撞上性能墙。
风险面要看明白。 仓库字节全部落在你配置的对象存储桶里,中继进程持有该桶的写凭据;kind:30618 这份 ref 状态事件是中继自己的密钥签的(文档的理由是中继对它托管的仓库的 ref 状态具有权威性),推送者的公钥只是挂在一个 p 标签里 —— 文档注明这个标签是 buzz 的扩展,NIP-34 并没有定义它。这条事件是 CAS 成功之后派生出来的通知,绝不是提交点:一次推送成功当且仅当指针完成了 CAS 交换,跟事件发没发、什么时候发无关;订阅者只能把它当作「去重读一次指针」的信号,不能当 ref 状态用。策略回调端点只绑本地回环,这条如果部署时被破坏(比如把它暴露到容器网络上),整个权限模型就直接失效了。至于 Agent 能改什么:被加进频道的 bot 按 Member 算,默认可以创建分支和标签、可以快进推送分支;强推、删除、移动标签需要 Admin。你希望 Agent 完全碰不到某个分支,就得显式给那个 ref 配 require-patch。
七、上手与避坑清单
git 版本卡两道门,别只升一个。 凭据助手要求 git 2.46+(依赖 authtype 能力),版本不够的表现是空输出、静默失认证,不是报错,能查半天。对象存储那条链路另有一道门:文档里说明它依赖 git index-pack --fsck-objects 的默认行为,可复现的最低版本是 git 2.31。装环境的时候一次对齐,别分两次踩。
先设 credential.useHttpPath 再试第一次 clone。 不设它,git 只按主机名缓存凭据,而 NIP-98 签名是绑到具体 URL 的,你会拿到一堆看不懂的 401。这条在源码里有专门的错误文案。
容器时钟先对,再排查认证。 签名事件的 created_at 必须落在服务端 ±60 秒内。CI runner 和长跑容器的时钟漂移非常常见,认证突然全挂而配置没动过,第一件事是查时间而不是查密钥。
别把 nsec 塞进 shell 内联命令。 NIP-GS 的安全考量把暴露面列全了:环境变量对进程自身和子进程可见,Linux 上同 UID 或 root 能读 /proc/<pid>/environ,内联赋值会进 shell history,CI 里被回显就进日志。文档给的定位是「这个暴露模型对受控环境里的 Agent 进程可接受」;至于安全要求更高的人类用户,规范用的措辞是实现「可以」在未来版本支持 NIP-46 远程签名(把私钥留在另一台签名器上、只把待签数据递过去),这是一条留口子的可选项,不是排期承诺,别当既有能力用。你至少要做到:密钥文件 0600、CI 里用密文变量而不是明文回显、别在错误信息里带密钥。
gpg.x509.program 用绝对路径,别信 PATH。 NIP-GS 明说了这个位置上如果被换成恶意程序,它既能偷密钥也能伪造签名;而仓库本地的 .gitconfig 是可以覆盖这一项的,克隆一个不受信任的仓库有可能把签名重定向出去。注入这项配置的桌面端应该从可信位置解析绝对路径。
并发推送拿到冲突不要在服务端加重试。 文档把这条写成不可谈判的规则:重试如果要加,只能在存储层、只重试分类之前的网络错误,绝不能重试已分类的 Ok(2xx)、竞争失败或 404 —— 重试一个已分类的结果会改变模型里的动作语义,把证明打穿。正确的重试是客户端重推,git 自己就会驱动。
换对象存储后端,先跑一致性探针再谈别的。 这套设计的全部安全性都压在「可线性化的条件写」这一条公理上。探针不过,不是配置调一调的问题,是这个后端不能用。
push:member 不是放行牌。 想当然地以为写了 push:member 就等于 Member 什么都能干,是这套规则里最容易踩的误解。显式角色只能加严不能放松,破坏性操作永远保持内置的 Admin 门槛。要放开强推,你需要的不是改 push:,而是重新想清楚为什么需要强推。
ref 模式不支持半截通配。 想写 refs/tags/v* 保护所有 v 打头的标签,会直接解析失败。这套语法只认整段通配,规则设计得围着这个限制走。
收束
这条链路的判断很清楚:它用「一个可变指针加一堆不可变对象」换掉了「一台有权威磁盘的服务器」,用「一次性签名事件」换掉了「长期令牌」,用「密钥即身份」换掉了「账号体系」。前两项换得干净利落,代价是并发浪费和延迟特性变了;第三项换来的是 Agent 和人共用一套身份,代价是没有吊销、没有找回,私钥丢了就是身份丢了,且过去所有签名依然有效。
想自己继续往下核,顺序建议是:docs/git-on-object-storage.md 的「Scope and Non-Goals」和「Axioms」两节先读,那里写着这套设计到底承诺了什么;然后 crates/buzz-core/src/git_perms.rs 底部的测试模块,那批测试名就是权限模型的行为说明书;最后 crates/buzz-relay/src/api/git/policy.rs 的模块注释,从校验 HMAC 到返回 200/403 的七步判定流程写在开头,是「我的 push 为什么 403」这类问题唯一的正解入口。至于工具怎么在你自己项目里落地成日常流程,可以对照Agent 改动边界约定那篇一起看。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 拆解 Block 开源 buzz 多 Agent 平台的 MCP 服务器 和 让 Agent 跑在集群里:Block 开源多 Agent 通信平台 buzz 的远程调度模型。