Block 开源 buzz 的审计链:多 Agent 平台里谁改了什么

2026-08-05

本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。

buzz 这条审计链证明的不是「某个人真的做了这件事」,而是「服务端记下的这段记录事后没被悄悄改过」——这两件事在工程上完全不同,把它们混起来,你就会对一套审计方案产生它给不了的信心。

先消歧:这里说的 buzz 是 Block 开源的多 Agent 通信平台(仓库 https://github.com/block/buzz ,许可证 Apache-2.0,Copyright 2026 Block, Inc.),人和 Agent 挂在同一张消息网络里协作,不是「热度」也不是「蜂鸣」。它建在 Nostr 协议之上——Nostr 是一套极简的消息协议,一条消息就是一段由作者私钥签名的 JSON 事件,由中继(relay,负责收存转发事件的服务器)保存和分发。本文只盯它的一个 crate:crates/buzz-audit,六个源文件(action.rsentry.rserror.rshash.rslib.rsservice.rs),Cargo.toml 里的自我描述是 “Hash-chain audit log for Buzz”。

站内已有几篇相邻的文章分工不同:Agent 可观察日志怎么埋 讲你自己系统里该记什么字段,Hermes 的监控与可观测 讲某个框架里监控链路怎么落地,日志里的敏感信息 讲写进去之后怎么不出事。本篇不重复这些,只回答一个问题:buzz 把管理动作串成一条带哈希的链之后,多出来的那点「可验证性」具体从哪来,又到哪为止。

一、可验证审计和普通日志差在哪

普通日志的信任模型是「我信这个文件」。你 tail 一下、扔进 ELK、告警一响,都建立在一个默认假设上:这些行是当时写下的原样。可一旦有人拿到写权限,改掉中间一行的 actor,或者整段删掉,从日志本身看不出痕迹——每行之间没有关系,删一行剩下的行依然自洽。

哈希链换掉的正是这个假设。buzz 的做法是:每条审计条目算一个 SHA-256,算的时候把上一条的哈希也放进去。于是第 N 条的哈希依赖第 N-1 条的全部内容,往前一路依赖到第一条。改中间任何一个字段,这条自己的哈希就对不上;想让哈希对上就得重算它自己,重算之后它的哈希变了,后面那条的 prev_hash 又对不上。要把整段改得自洽,必须从被改那条一直重算到链尾。

这带来的性质叫 tamper-evident(可发现篡改),不是 tamper-proof(不可篡改)。crates/buzz-audit/src/lib.rs 的头注释用的就是 tamper-evident 这个词。区别很实在:链不阻止任何人改数据库,它只保证改完之后有一个明确的检查能红。这个检查是否有人跑、多久跑一次、结果给谁看,是运维问题,不是密码学问题。

二、一条条目被哈希成什么

audit_log 表由 migrations/0001_initial_schema.sql 定义(全仓 migrations/ 目录下共 27 份 SQL,审计表在第一份里):

CREATE TABLE audit_log (
    community_id    UUID NOT NULL REFERENCES communities(id),
    seq             BIGINT NOT NULL,
    hash            BYTEA NOT NULL,
    prev_hash       BYTEA,
    action          VARCHAR(64) NOT NULL,
    actor_pubkey    BYTEA,
    object_id       TEXT,
    detail          JSONB,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (community_id, seq)
);

CREATE UNIQUE INDEX idx_audit_log_hash ON audit_log (community_id, hash);

主键是 (community_id, seq)seq 在一个 community(社区,buzz 的租户单位)内单调递增,prev_hash 也只链向同一个 community 的上一条。换句话说,每个租户一条独立的链,互不穿插。

哈希的字段顺序写在 crates/buzz-audit/src/hash.rscompute_hash 里,是固定的:

let mut hasher = Sha256::new();
// Tenant binding: community_id leads the hash.
hasher.update(entry.community_id.as_bytes());
hasher.update(entry.seq.to_be_bytes());
hasher.update(
    to_storage_precision(entry.created_at)
        .to_rfc3339()
        .as_bytes(),
);
hasher.update(entry.action.as_str().as_bytes());

几个设计点值得单独看:

community_id 排在最前面,注释说明了意图——链的身份自带租户,一行记录不能从 A 社区的链里抬出来塞进 B 社区再验证通过。service.rs 里有一个开着数据库跑的测试 cross_community_row_does_not_verify 就是伪造这个场景:把 A 的 seq-1 行的哈希原样插进 B 的链,验证 B 时用 community_id = B 重算,对不上,报 HashMismatch

可选字段前面带一个存在位。actor_pubkey(动作发起者的 Nostr 公钥原始字节)和 object_id 都是 OptionSome 前先 update([1u8])Noneupdate([0u8])。少了这个字节,Some(空)None 会哈希成同一个值,测试 presence_tag_distinguishes_none_from_empty 钉的就是它。

detail 这个 JSONB 字段进哈希,走的是文件里的 canonical_json:对象键用 BTreeMap 排序后再序列化,保证同样的 JSON 在不同机器上得到同一个字符串。序列化失败直接返回错误,注释明确说了不拿空值顶替真实载荷——审计链里「静默降级」等于自己给自己开后门。

第一条条目的 prev_hash 存 NULL,但哈希时按 GENESIS_HASH(32 字节全零)参与计算。

最容易踩、也最能说明这类设计脆在哪的是时间精度。created_atTIMESTAMPTZ,Postgres 只保留到微秒;而哈希覆盖的是 created_at.to_rfc3339() 这个字符串,chrono 生成的小数位数会跟着值变(0、3、6 或 9 位)。于是一个带纳秒的时间戳写进去时按 9 位算哈希,读回来只剩 6 位,重算的前像根本不是同一个字符串——hash.rs 的测试注释写得很直白:这曾让每条条目都以 HashMismatch 失败。修法是 to_storage_precision 把时间截到 6 位小数,并且在 compute_hash 内部再截一次(截断是幂等的,不改变已有摘要),这样将来哪个写入路径忘了截断,也不会重新裂出「写用一个前像、验用另一个前像」的问题。

三、链怎么长出来、怎么验

追加走 AuditService::logcrates/buzz-audit/src/service.rs)。顺序是:从连接池取连接,先拿一把按社区区分的 Postgres advisory lock(会话级咨询锁,只在应用内约定语义,数据库不管你锁的是什么)——锁键是 buzz_audit: 拼上 community id,在库里通过 SELECT pg_advisory_lock(hashtextextended($1, 0)) 变成一个 i64。注释说明了为什么不用一把全局锁:那既是吞吐瓶颈,也是一条跨租户的时序侧信道。

拿到锁之后 log_inner 开事务:按 community_id 查出这条链的链头(ORDER BY seq DESC LIMIT 1),seq = prev_seq + 1,没有链头就是这个社区的第一条;打上 log_timestamp()(即已经截到微秒的当前时间),算哈希,插入,提交。log 外层用 AssertUnwindSafe + catch_unwind 包住 log_inner,目的是即使里面 panic,也先把咨询锁放掉再把连接还给池子,然后 resume_unwind 继续抛。

验证走 verify_chain(community, from_seq, to_seq),只做两件事,对应两个不同的错误:

  • 相邻两条的链接关系:上一条的 hash 必须等于这一条的 prev_hash,不等报 ChainViolation { seq }
  • 每条自己的完整性:按行里的字段重算 compute_hash,和存的 hash 不等报 HashMismatch { seq }

区分这两个错误对排查很有用:HashMismatch 指向「这一行的内容被改过」,ChainViolation 指向「行与行的顺序关系被动过,比如插入或删除」。

AuditError 的五个取值(DatabaseChainViolationHashMismatchUnknownActionSerialization)都不带 community_iderror.rs 里有一个测试专门断言错误文本里不出现 community id、不出现约束名和 communities 这类标识符,同时断言 seq 还在——seq 是社区内的编号,脱离它自己的链没有意义。这是一个值得抄的习惯:审计错误往往会被回抛到某个共享面,错误文本本身就是信息泄露面。

组成部分它负责什么仓库位置你什么时候会碰到它
AuditAction 枚举动作取值与稳定的字符串表示(as_str / FromStrcrates/buzz-audit/src/action.rs想新增一类被审计的动作时
AuditEntry / NewAuditEntry存储态条目与入参结构,入参的 community_id 是服务端解析出的类型crates/buzz-audit/src/entry.rs在业务路径上组装一条待写条目时
compute_hash / to_storage_precision / GENESIS_HASH字段序、canonical JSON、时间精度归一crates/buzz-audit/src/hash.rs改字段、改时间处理、排查哈希对不上时
AuditService加锁追加、verify_chainget_entriescrates/buzz-audit/src/service.rs跑一致性核对、导出一段链时
AuditError五类错误,且不携带租户标识crates/buzz-audit/src/error.rs处理验证失败、看日志文本时
audit_log 表与唯一索引落库结构,主键 (community_id, seq)migrations/0001_initial_schema.sql定容量、加索引、做归档时

四、这条链在 relay 里挂在什么位置

buzz-audit 本身只有链逻辑,不带 DDL、不管调度,接线在 relay 那侧。crates/buzz-relay/src/state.rsAppState 有两个相关字段:audit: Option<Arc<AuditService>>audit_tx: Option<mpsc::Sender<buzz_audit::NewAuditEntry>>。业务路径不直接写库,而是把 NewAuditEntry 投进一个容量 1000 的有界队列,后台 worker 串行消费。

这里有个明确的取舍写在 handlers/event.rs 的注释里:入队用的是 send().await 而不是丢弃式的尝试发送,队列满了就把背压传回事件处理路径。理由是审计写入本来就被咨询锁串行化(同一社区最多一个在飞),队列满意味着审计库真的过载,此时让 relay 慢下来比在内存里堆无界状态更好。worker 里写库失败会记 buzz_audit_log_errors_total、打日志,但不重试;关停时 AuditShutdownHandle::drain(timeout) 负责把队列里剩下的条目冲干净,超时就照样退出。

action.rs 的枚举列了 11 个取值:EventCreatedEventDeletedChannelCreatedChannelUpdatedChannelDeletedMemberAddedMemberRemovedAuthSuccessAuthFailureRateLimitExceededMediaUploaded。但在这份快照里,我在 buzz-audit 之外只找到两个入队点:crates/buzz-relay/src/handlers/event.rsEventCreateddetailevent_kindchannel_id),crates/buzz-relay/src/api/media.rsMediaUploadeddetailsha256sizemime)。其余取值在这份代码里没有找到发起处。看审计方案时这一步不能省——枚举里有,不等于线上真的在记。

event.rs 那段注释还点了一个容易做错的地方:actor_pubkey 记的是调用方解析出的动作发起者,不是事件里的 pubkey。因为有些事件是 relay 用自己的密钥签的(工作流投递、副作用发出),从事件里取作者会把背后那个真人从记录里抹掉。审计里的「谁」和协议里的「签名者」不是同一个概念。

开关是环境变量 BUZZ_AUDIT_ENABLED,默认 true(crates/buzz-relay/src/config.rs),字段注释还特别说明它管的是事件与媒体的审计日志,不控制另一套 moderation_actions 审计轨。

五、边界与代价

这套设计放弃了不少东西,而这些恰恰决定它适用在哪。

链不签名,所以不对抗掌握写权限的一方。 compute_hash 覆盖的字段里没有任何签名,链是纯 SHA-256,且是 relay 自己算、自己写。谁能改 audit_log,通常也能重算被改条目之后的整段链,让 verify_chain 重新变绿。这份快照里没有把链头对外锚定或让第三方共同签名的机制。所以它的强度是:防住误改、防住只改一行的改动、把「有人动过」变成可发现——不是防住有库权限的内部人。

它不证明动作真的发生过。 「这条记录内部自洽」和「这个公钥的持有者真的发了这条消息」是两层。后者由 Nostr 的事件签名负责:事件由作者私钥签名,中继校验签名。审计链只是把 relay 的处理结果按顺序钉住。密钥自持这件事在这里要说清:Nostr 身份就是密钥对,私钥在持有者手里,丢了私钥等于丢了身份,也没有哪个管理员能替你找回;审计链对此无能为力,它记得住 actor_pubkey 是哪串字节,记不住这串字节背后的人当时是不是自己在操作。

跨租户的可比性被主动放弃了。 每个社区一条链,没有全局序。你无法从链上回答「A 社区的这条和 B 社区的那条谁先发生」,因为 seq 只在社区内有意义。这是有意的:lib.rs 把它对应到 docs/spec/MultiTenantRelay.tla 里的 auditHeads[c](TLA+ 是一种形式化规范语言,用数学模型描述系统状态迁移并让工具穷举检查不变式;这里的意思是一次审计观察只暴露它自己社区的链头)。想做跨租户时间线,得另建东西。

不上链的部分。 运营侧的处置动作走另一张表 moderation_actionsmigrations/0006_moderation.sql):那张表有 actor_pubkeyactionreason_codepublic_reasonprivate_reason 这些列,没有 hash、没有 prev_hash——它是结构化的普通审计行,不是哈希链。别看到「buzz 有哈希链审计」就以为全部管理动作都在链上。

detail 是明文 JSONB,并且进哈希。 entry.rs 的注释直接写了红线:这里绝不能放 bearer token、口令这类凭据;AuthSuccess/AuthFailure 只携带结果元信息。两个原因叠在一起——这个字段原样落库(谁能读库谁就能读到),而且它进了哈希,写错了之后连「悄悄改掉」都做不到,改了链就红。

没有对外的审计查询面。 一致性测试 crates/buzz-test-client/tests/conformance_multitenant.rs 里有一段说明:relay 的路由表里没有审计端点,审计是 ingest 的副作用写入,读只能通过 verify_chain / get_entries 这两个运维内部接口。对合规场景来说,这意味着「给租户自助导出审计」这件事需要你自己补一层,并且补的时候要重新想清楚跨租户隔离。

它落在一个 Postgres 里。 链的耐久性等于这套库的备份策略。库整体丢了,链一起丢;链本身不提供异地副本或外部时间戳。

六、上手与避坑清单

  • 时间戳精度:为什么会踩——你按直觉用 Utc::now() 组装条目,Linux 上带纳秒,写库被截到微秒,RFC3339 字符串的小数位数随之变化,重算的前像不再是原来那串,全表验证失败。怎么避——任何进哈希的时间字段先过一次统一的截断函数,并且把截断放在计算哈希的那一个点上,让忘记截断的调用方也不会出事;再补一个不依赖数据库的单测钉住这个不变式(service.rs 里的 log_timestamp_carries_no_sub_microsecond_digits 就是这么做的)。
  • verify_chain 对空范围返回 Ok(false):为什么会踩——你把返回值当布尔「通过/不通过」用,某个社区还没有任何条目,你的巡检脚本就报「审计校验不通过」,或者反过来把 Ok(false) 当成没问题跳过。怎么避——把三种结果分开处理:Ok(true) 是这段区间自洽,Ok(false) 是区间里没有行,Err 才是真出事,并且按 ChainViolationHashMismatch 分流告警。
  • 链相关的强测试默认不跑:为什么会踩——service.rs 里那批多社区隔离、篡改检测的测试都带 #[ignore = "requires Postgres"],本地 just test-unit 跑绿了你以为链逻辑被覆盖了。怎么避——CI 里单独安排一条带库的任务跑这些被忽略的测试;本地改哈希字段时,至少手动跑一遍。
  • 改字段顺序等于废掉历史链:为什么会踩——你往 AuditEntry 加一个字段,顺手插在中间,或者调整 hasher.update 的次序,觉得「反正都进哈希」。怎么避——hash.rs 的注释说明了字段序是固定的、改动会让所有已有链失效;要动就得把它当成一次数据迁移来设计(比如新链另起、旧链只读保留),而不是一次重构。
  • action 字符串是存储格式:为什么会踩——你重命名枚举变体、顺手改了 as_str 的返回值,旧行读回来时 FromStr 匹配不上,row_to_audit_entry 直接返回 UnknownAction,那一整段链验不了。怎么避——把 as_str 当成对外契约看待,改枚举名可以,改字符串要配迁移。
  • 别把 ARCHITECTURE.md 当代码读:为什么会踩——根目录 ARCHITECTURE.md 的 buzz-audit 小节说有 10 个审计动作、哈希覆盖 event_id / event_kind / channel_id / metadata,还提到一个 AuditError::AuthEventForbidden;而这份快照的代码里枚举是 11 个取值,字段是 object_iddetailerror.rs 里也没有那个错误取值。怎么避——文档与代码在高频迭代的仓库里会错峰,涉及哈希前像这种细节一律回源码核。顺带一提,根目录同时放着 8 份 VISION*.md,那些写的是愿景不是已实现的功能,读的时候要分清。

收个尾

如果你要照这条思路给自己的 Agent 平台加审计,先回答四个问题:进哈希的字段清单定了没有(含可选字段的存在标记和 JSON 的规范化)?时间与数值的表示是不是可以稳定往返数据库?验证由谁在什么节奏上跑、结果报给谁?以及最关键的——你要防的对手是谁,如果答案是「有库权限的内部人」,那么单靠一条服务端自算的链不够,得再想外部锚定。

想继续读代码,顺序建议是 crates/buzz-audit/src/hash.rs(前像定义,注释密度最高)→ service.rs(加锁与验证,连带那批带库测试)→ crates/buzz-relay/src/state.rs 里的审计队列装配 → handlers/event.rs 中的入队点,看一条真实动作从业务路径走到链上的完整路线。相关的工程侧话题,站内另有一篇讲 Agent 改动边界怎么约定,审计链只能记住越界发生过,拦不拦得住是另一层的事。

本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源 buzz 多 Agent 平台:多租户隔离线要画三遍Block 开源多 Agent 通信平台 buzz 数据层怎么改表不停机

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。