日志是证据吗?——给 Agent 审计轨迹加一条哈希链

事故复盘会上,你把日志翻出来:Agent 那天下午 3 点 12 分确实执行了删除操作。Agent 的开发者说不可能,日志是假的。运维说文件权限只有 root 能动。安全团队问了真正致命的一句话:谁能证明这条日志没被改过? 会议室安静了。因为答案是:没人能证明。 这篇文章讲的就是如何把这句话的答案,从"没人能证明"变成"可以验,这是验证命令"。

一、"我写了日志"不等于"我有了证据"

大多数 Agent 平台的做法是:把事件追加写到一个文件里(JSONL、数据库、或丢给可观测平台)。这在工程上完全合理——但它解决的是可观测性,不是证据性。两者的差距有三道坎:

第一道坎:追加写只挡住"修改",挡不住"删除"和"补写"。 日志是 append-only 的,所以没人能改写第 100 行的内容。但删掉第 100 行,或者在第 100 行后面再补一行"撤销操作"——文件的形态依然是合法的。

第二道坎:权限挡不住有权限的人。 chmod 640 防的是外部攻击者,防不了拿到了运维权限的内部人员。而合规场景真正要防的,恰恰是内部风险——审计师关心的是"记录者能不能自证清白"。

第三道坎:可观测平台的数据是可以被编辑的。 把日志送进第三方平台只是换了个地方存储,平台侧依然能删能改能过滤。它给你"看得见",不给你"证得出"。

一句话:日志是描述,证据是可验证的描述。差别在于后者有一个"验"的动作——拿到数据的人,不需要信任提供数据的人。


二、哈希链:让"删一条"变成"断一条"

思路不新鲜(区块链、证书透明日志、Git 都是它),但用在 Agent 审计轨迹上很合适。

每条审计条目存两个额外字段:

prevHash   = 上一条条目的 chainHash
chainHash  = HMAC(key, stableJson(条目内容 + prevHash))

第 0 条的 prevHash 取一个固定的创世种子(AUDIT_GENESIS_HASH),于是整条日志变成一条链。它带来三个性质:

篡改动作 后果
改第 k 条的内容 第 k 条的 chainHash 变了 → 第 k+1 条的 prevHash 对不上 → 从第 k 条起全部断裂
删掉第 k 条 第 k+1 条的 prevHash 指向一个不存在的链值 → 从第 k 条起断裂
追加伪造一条 没有密钥就算不出合法的 chainHash → 伪造条目立刻可识别

关键点在于:"删一条"不再是无声的消失,而是制造一个能被定位的断裂点。验证函数返回的不是"通过/不通过",而是"在第 k 条断裂"——审计师拿到的是坐标,不是感觉。


三、在 Orbit 里怎么用

签名是可选的,但不签名就只能证明结构、不能证明未篡改,所以产品里把这件事做成了显式的档位约束。

import { OrbitRuntimeHost } from "orbit-runtime";

const host = new OrbitRuntimeHost({
  governanceProfile: "strict",   // 合规档:强制签名
  auditSigningKey: process.env.ORBIT_AUDIT_KEY,
  traceJournalPath: "./trace.wal.jsonl"
});

await host.bootHost();          // 启动即验证整条链;被篡改 → 拒绝启动

host.traceJournal.append({
  entryClass: "GRAPH_RUN",
  traceMarkId: "run-42",
  factPayload: { graph: "research", calls: 3 }
});

const verdict = host.verifyAuditChain();
// { total: 128, signed: true, consistent: true }

命令行同样可验——这是给审计师的那一条命令:

orbit audit trace.wal.jsonl --key "$ORBIT_AUDIT_KEY"
# entries : 128
# signed  : true
# result  : ✓ audit chain consistent (no tampering detected)

控制台的审计页会显示链卡(条目数 / 是否签名 / 是否一致),以及一份把"档位 + 审计链状态 + 治理干预统计"收敛在一起的合规报告(Markdown / JSON / PDF 可导出)。


四、三个诚实的设计决策

真正让这套机制可用的不是哈希链的数学,而是几个不讨巧的取舍。

1. 崩溃之后:末行可以丢,内部行必须硬拒

日志落盘用的是"一次写入只追加一整行"的 WAL,所以进程被杀时的唯一残留形态是:最后一行写了一半。恢复时必须二分处理:

  • 末行非法 → 丢弃(这是崩溃的正常残留)
  • 内部行非法 → 直接报错拒绝启动(内部行不可能被崩溃截断,静默跳过等于隐藏损坏)

这里有个反直觉的坑:那行被截断的残留仍然在磁盘上,如果本次运行第一次追加之后它才被发现,它就从"末行"变成了"内部行"——于是一次崩溃会让此后每一次启动都失败。所以恢复后必须在首次追加前把末行治愈(比对已恢复行数与磁盘行数,健康的日志零重写)。这个坑我们踩过,也把它写进了测试。

2. 未签名就是未签名,不叫通过

合规报告的状态是诚实推导出来的四态,不是"没报错就算好":

状态 含义
EMPTY 还没有审计条目
UNSIGNED 有条目但没签名——只能证明结构存在,不能证明未被篡改
PASS 已签名且链一致
FAIL 链断裂,报告里写明断裂位置与原因

把 UNSIGNED 单独列出来,是为了不让"看起来在审计"冒充"审计过"。给自己的产品留这种不体面的状态,比事后被审计师拆穿便宜得多。

3. HMAC 有边界,那就把边界补上

链用的是 HMAC(对称密钥):它能证明"持有密钥的人没有改过",但验证方必须也持有密钥——这让"给外部审计师验证"变成了"把密钥给他",这显然不能接受。

所以报告层补了非对称签名(W35):合规报告用 ED25519 签名,密钥对由一个 32 字节种子确定性派生(备份种子即可在任何机器复原),验证方只需要一份公钥:

orbit verify-report report.json --public-key auditor.pub.pem
# ✓ signature valid (ed25519 · b050a108aa3b386d)

于是分工是清楚的:哈希链证明"这条轨迹没被改",报告签名证明"这份报告出自运维之手且未被改"。前者对内,后者对外。


五、边界:哈希链挡不住什么

必须说清楚,否则就是过度承诺:

  • 挡不住签发者撒谎。 持有密钥的人可以重签整条链——哈希链防的是"事后修改",不是"最初就记假账"。要防这个,需要外部锚定:把链尾哈希定期写到 WORM 存储、时间戳服务或公链上。这是 Orbit 明确规划的后续路径,当前未实现。
  • 密钥管理才是真正的难点。 链的强度等于密钥的保管强度。密钥进代码仓库、进环境变量明文、进容器镜像,链就形同虚设。
  • 代价是 O(n) 验证。 每次追加是 O(1),但完整验证需要遍历全链。当前实现选择启动即全验(strict 档),对超长轨迹未来需要分段摘要。

承认这些不会削弱机制的价值——它依然把一个"无法回答的问题"变成了"一条命令可回答的问题"。只是答案的范围要标注清楚。


结语

Agent 开始动生产数据时,"它做过什么"就不再是个技术问题,而是举证责任问题。日志解决"我记得",哈希链解决"我证明",非对称签名解决"你也能验"。

这三层的成本都不高(零额外依赖、每次追加 O(1)),但没有它们,你在事故复盘会上的每一句话都只是主张。

Orbit 把这套机制放在内核的审计层,而不是做成外挂的日志组件——因为可证明性是运行时属性,不是存储属性。

相关阅读:Agent Bug 为什么不可复现 讲的是同一个故事的另一半:重放解决"发生过什么",审计链解决"记录没被改过"。两者合起来,才构成"可复现 · 可证明 · 可核算"。
posted @ 2026-09-05 22:47  zzuCharles  阅读(24)  评论(0)    收藏  举报