开源库obstat实现了一种审计架构反转——在工具函数体执行前通过fsync将授权决策同步落盘,即使进程崩溃也能区分「未授权所以未执行」与「执行后崩溃」两种情况,堵住了传统事后写日志的安全漏洞。
几乎我见过的每一个工具治理层,都是在调用返回之后才写日志的。有的是在 finally 里写,有的是批量写,有的是交给某个自行决定何时刷新(flush)的日志框架来写。
这种顺序上的安排,悄悄地决定了你的日志能用来做什么。
如果记录是在函数体运行之后才写的,那么一条缺失的记录就有两种可能的解释,而日志文件本身无法区分它们:
调用从未获得授权,所以从未执行。
调用获得了授权,执行了,做完了它的工作,但进程在日志行落地之前就崩溃了。
这两种情况截然不同。前者是控制机制在正常工作。后者是一次未被记录的操作。当六周后有人问你"凌晨 3 点 14 分你的 Agent 被允许做什么"时,"没有对应的日志行"什么也说明不了。
所以我写了一个小程序库,把这个顺序倒了过来。
obstat 是一个面向 Agent 工具调用的可审计决策记录。"Nihil obstat"——"没有任何阻碍"——是审查员在出版前以书面形式给予的正式放行令。这就是整个设计的思想。
from obstat import guard
@guard(resource="doc:{doc_id}")
def delete_document(doc_id: str) -> str: ...
Agent 发起一个请求,规则做出决策,决策在工具函数体执行之前就写入磁盘——写入并完成 fsync。如果进程在调用中途崩溃,记录仍然会说明:谁获得了授权,对哪个资源操作,依据是什么。
record.decision() 只有在 fsync 返回之后才返回。不是之后才刷新,不是 deferred,不是批处理。库里的其他一切都是便利设施;这是审查员所依赖的那一部分。
论断有测试支撑,而非一段话
一个无人能证伪的架构承诺,就是营销话术。这个论断通过从工具函数体内部读取日志来检验——在那个位置,任何被缓存、deferred 或事后写入的东西都是不可见的:
def test_record_is_durable_before_the_body_runs(workspace):
workspace(ALLOW_ALL)
seen: dict[str, list] = {}
@guard()
def read_thing(what: str) -> str:
# 从函数体内部读取磁盘上的日志。任何被缓存、deferred
# 或事后写入的东西在这里都不可见——这正是关键所在。
seen["records"] = record.read()
return f"read {what}"
assert read_thing("a-file") == "read a-file"
decisions = [r for r in seen["records"] if r["phase"] == "decision"]
assert len(decisions) == 1
assert decisions[0]["effect"] == "allow"
把写入操作往后挪一行,测试就会失败。这就是那个属性,用一种一旦不再为真就会破裂的形式表达出来。
结果记录——是否成功、是否抛出异常——是在之后写入的,且有意不做持久化。如果进程在两个阶段之间崩溃,日志会显示"已授权,结果未知",这才是诚实的状态。为了一个仅仅具有信息价值的字段支付第二次 fsync 的代价,是错误的权衡。
"记录即产物"这一理念的后续
授权是按资源而非按层级来的。READ / WRITE / DESTRUCTIVE 无法表达"可以编辑自己的工单,但不能编辑你的"。obstat 从调用参数中解析出资源 ID,然后将规则与该资源进行匹配:
[[rule]]
subject = "human:ana"
resource = "jira_issue:ACME-*"
effect = "allow"
一次批准绑定于一次调用。它携带着工具名、主体、资源,以及参数的摘要,且只可使用一次。批准"删除 q3-report"不能被用来删除其他东西,也不能被使用两次——这在一笔 BEGIN IMMEDIATE 事务中强制执行,所以两个并发重试不可能同时成功。花掉它的那条记录会指名是谁批准的,因为"谁说了可以"不应该只存在于一个可变的 SQLite 行里。
参数做指纹处理,不做存储。工具参数携带凭证和个人数据;一份泄露这些数据的治理日志不是控制手段,而是隐患。你命名出人类需要看到的那些参数,且只有那些值会被记录——因为审批者对着 sha256:ae32e6… 做决定,实际上什么都没有审。摘要仍然覆盖了全部内容。
每条记录都携带前一条记录的哈希值,所以被编辑或删除的行会在 obstat verify 中显现出来。
三个真实邮箱的发现
在写这篇文章之前,我把自己的邮箱前面也架上了 obstat:三个 IMAP/SMTP MCP 服务器——一个个人邮箱、一个 Gmail、一个接收陌生人邮件的公开商务地址——所有出站消息都置于批准之后。使用发现的东西是 review 没有发现的。
Agent 在第一天就绕过了门禁。它问各个邮箱有多少未读消息,找不到能回答这个问题的受保护工具,于是直接用服务器进程所持有的凭证打开了原始 IMAP 连接,并正确回答了问题——两个邮箱共 2360 封未读——但整个过程没有任何记录。没有任何操作失败。门禁 simply 不在它走的这条路上。
这个发现现在是 §8 的第一条,因为它是读者最容易误读过去的那一条:
记录覆盖的是门禁,不是资源。缺失只有在通过了 @guard 的调用中才有证据效力。其他一切看起来都是安静的,而不是不完整的。
调用者能读取的凭证,就是调用者能绕过的门禁。这种分离必须来自宿主侧——不同的账号、沙箱、没有 shell 的会话。obstat 针对的是标准的 MCP 部署方式,那里工具接口就是全部的攻击面;而一个旁边放着 shell 的编码 Agent 不是。
覆盖范围本身就是控制。工具接口无法回答的问题,会成为记录上的一个空洞,而不是一个拒绝响应。count_unread 现在存在于那个服务器上了,因为它当时不存在。
这个库预留了一个邮件工具需要的词。obstat 把调用者身份注入了一个叫 subject 的参数——而邮件工具需要的是 send_email(to, subject, body)。危险的失败不是崩溃;而是安静的那种变体——参数从广告模式(advertised schema)中消失,一个身份对象落到了 Subject: 头里。现在是 obstat_subject,而 obstat_ 是这个库唯一预留的前缀。
记录说明了什么被授权了,从来不说什么发生了。批量删除记录的是一个发件人,无论它删了一条还是一万条,结果都是 ok: true,两种情况都一样。工具现在可以从函数体内部向结果记录写入 obstat.note(deleted=…, matched=…)——失败了也写,因为批量删除删了一半是读者最需要一个数字的情况。
glob 匹配整个字符串,smtplib 会向头部中的每个地址投递。"邮件给自己是自由的"规则——资源 mail:*@example.com——同时也匹配了 attacker@evil.example,me@example.com,而 send_message 本来会向两者都投递。资源 ID 是调用者控制的文本:在资源 callable 中解析它,而不是做模式匹配。那个 callable 抛出的任何异常都会变成一条有记录的拒绝,而不是一次无记录的崩溃。
这些发现没有一个来自 review,其中两个是 obstat 在承认自身的局限性,而不是在修 bug。这就是我想要明确说清楚的权衡:这个库能让礼貌的路径留下证据。它不能让所有路径都变得礼貌。
截断的尾部不会显现出来。任何能写这个文件的人都能重新计算整条链。这是防篡改证据(tamper-evidence),而不是不可否认(non-repudiation),规范文档里用的就是这个词——docs/obstat-spec.md 的 §8 列出了仍然薄弱的地方,有意做得和功能列表一样显眼。
其中一条是 CI 发现的,不是我。并发测试——两个真实进程追加到同一个日志——在 Linux 和 macOS 上都过了,在 Windows 那条腿上回来时只有 60 条记录中的 57 条。Windows 的追加模式是一次 seek 加一次 write,而不是一个原子操作,所以并发写入者会静默丢失记录。跨进程保证现在已标注为仅限 POSIX,修复方案也已命名(msvcrt.locking(),这恰恰是这个设计拒绝采用的那个进程间锁),测试在 Windows 上跳过,CI 那条腿保留着。我宁愿发布一个被记录在案的漏洞,也不愿发布一个没有文档的漏洞。
那个测试是在两个没有触及线程或进程的发布之后才写的。教训可以推广:当一个规范性论断没有测试支撑时,bug 就在那里——不在那些每天都被执行到的代码里。
四个版本之后,同样的模式又抓住了另一个问题,这是我觉得最有教育意义的一个。规范说的是如果调用参数不符合工具就拒绝。代码只做了部分绑定,所以一个缺少必需参数的调用通过了门禁,拿到了 allow 记录,然后在函数体里以 TypeError 崩溃——日志声称一次调用已被授权,而它根本不可能执行过。这恰恰是这个项目存在的意义——不产生这种不实论断——而它就那样待了四个版本。
它存活下来是因为 MCP SDK 在调用到达装饰器之前就会根据广告模式验证参数。通过一个服务器,坏调用根本进不来,所以这个缺口从外部是不可见的;我只是通过写了一个直接调用被保护函数的测试才看到它。两点结论随之而来。一条仅因为上游恰好谨慎行事才成立的保护,不是你的保护。一条用你的用户使用你的代码的方式去测试你的代码的测试,系统性地会错过你的用户的工具链最先过滤掉的那些情况。
pip install obstat
obstat init # 一个起始策略;一切拒绝,直到你取消注释一条规则
零运行时依赖。不是 AWS,不是身份提供者,不是策略服务——只有装饰器、tomllib、sqlite3 和一个文件。一个没人能在笔记本上试用的治理库,就是一个没人会采用的治理库。
身份也是可选的。当下大多数 MCP 服务器根本没有 token:stdio、一个本地用户、或者一个已经终结了认证的网关。在你能评估一个治理库之前就要求一个身份提供者,这就是治理库无人问津的原因。一次匿名调用在这里是合法的调用——它被记录为匿名,策略决定匿名可以做什么。
docs/obstat-spec.md 是规范性的:行为的变更在同一 commit 中更新它,当规范和代码不一致时,其中一个就是 bug。
Apache-2.0。我特别希望听到那些真正被问过"你的 Agent 在什么时候被允许做什么"这个问题的人的声音,因为我是在自己对那场对话的猜测基础上构建这个的。