通过将每请求启动新进程改为Rust守护进程+预热worker,策略检查P50延迟从700ms降至约0.7ms,并首次实现fail-closed可声明保障。
Failproof AI Enforcement 每次工具调用都作为一个全新的进程运行:在某条策略看到命令之前,光是运行时启动就花了 700ms,而且没有办法从外部判断是正常安装还是损坏状态。用 Rust 守护进程加预热 Worker 替换后,p50 降到了约 0.7ms,也首次让"故障即关闭"成了一句可以明确说出口的保证。
failproof ai 为编码 AI 智能体 harness 提供运行时 enforcement:Claude Code、Codex、Copilot、Cursor 等八种。运行时 enforcement意味着检查同步进行,位于执行路径之中,在工具调用运行之前,而非事后在日志某处马后炮。在 harness 运行一条命令之前(一次强制推送、一个 rm -rf、写入一个泄露 API key 的文件),failproofai 会先按策略对其进行评估,并可以直接阻断。
在其大部分生命周期里,这套 enforcement 都是作为全新进程运行的,每个 harness 发起的工具调用都会重新生成一个。这样做很简单,但有两个问题:其一,速度慢;更糟糕的是其二,如果那个进程启动失败,harness 根本不会停下——没有 enforcement,也没有警告。我们无法从外部判断是正常安装还是损坏状态。解决方案是:不再每次调用都启动一个新进程,而是让机器上运行一个长期存在的守护进程,这是我们真正能检查其健康状态的,而且它有足够的存活时间来运行预定的本地审计并保持策略与云端同步。这篇文章按此顺序解释原因。
每个 harness 都支持同一种基础钩子:在运行一个工具之前,先运行这条命令,向其传入一个 JSON payload,再读取裁决结果。

这是一种很好的项目起步方式。不需要安装为服务,不需要保持运行,不存在内存泄漏或过期之说。它交付了,也解决了真实问题。但在三个特定场景下崩溃了。
它太慢了。启动一个 JS 运行时需要约 700ms,而这期间没有任何一条策略在看那条命令。再加上:读取三级配置、注册 40 条内置策略,如果团队有自定义策略,还要重写它们的 import 并写入临时文件来加载。所有这些在每个可能包含数百次工具调用的会话中,每次调用都要从头跑一遍。用户不会保留一个在每条命令上都添加明显停顿的工具。
它无法故障关闭。运行时 enforcement 必须能够做到"如果我无法评估,就阻断它"。我们无法提供这一点,因为没有任何东西能够注意到进程启动失败。如果运行时移出了 PATH,如果厂商改变了其钩子配置 schema,如果二进制文件干脆就不在了,harness 都会继续执行,零 enforcement。没有错误,没有日志行,什么都没有。唯一的信号就是 enforcement 停止——而它本来就被设计成不可见的。
没有任何地方可以保持长驻。两样我们需要的东西——从中央服务器同步策略,以及定期扫描会话历史——都需要在调用之间保持存活的东西:一个轮询循环、一个调度器、一个指向已上传内容的游标。一个只存活 40 毫秒的进程无法承载其中任何一样。
解决方案是机器上一个进程——用 Rust 编写的 failproofaid——持续运行并监管一个预热 Worker,后者进行实际的策略评估,Policy Engine 仍然是 TypeScript 代码,与之前一样。

有三个选择值得解释:
策略引擎没有迁移。它还是同一份 TypeScript 代码,只是运行在一个两次调用之间不会退出的进程中,而不是之前那样每次都重新生成一个进程。同时重写策略逻辑和改进程模型意味着两件未知事物同时上线。
Rust 用于 supervisor,而不是为了速度。它带来的是:单个静态二进制文件,无需在 PATH 上定位任何东西(这正是之前出错的确切原因);一个真实的文件锁,防止两个守护进程争抢同一个 socket;以及一个足够无聊的进程,能连续几周保持运行,而其下的 Worker 可以被重启。
Worker 在守护进程启动时就预热好了,在任何真实请求到来之前,因此 700ms 的启动成本发生在一次启动时,而不是每次重启后第一次 hook 调用时。
failproofai 和 failproofaid 之间的 wire
failproofai(小写,无 d)现在是精简部分。它是 harness 实际上在每次工具调用时生成的东西,而且它几乎只做一件事:与守护进程通信并转发答案。

这条 wire 有几处是刻意设计的:
每个消息,在两个方向上、在两个 socket 上,都是同一形状:4 字节长度前缀加对应字节数的 JSON。ping/pong 是存活检查。hook 携带 harness 本身写的原始 payload,逐字节转发。hookResult 携带 exit code 加 stdout 和 stderr。还有一种独特的错误类型"我完全无法产生裁决结果",与真实结果分开,以便客户端能够区分"运行了并做出了决定"和"根本没有得到答案"——这正是故障关闭所依赖的精确区分。
守护进程通过自己的第二条 socket 与 Worker 通信,而不是用 Worker 的 stdin 和 stdout,因为自定义策略是用户写的任意代码,一条调试遗留的孤零零的 console.log 会落到 stdout 上,从而破坏共享的成帧通道。保持 Worker 真实的 stdout 干净、不含协议字节,意味着一条不小心的 print 语句只是一个 cosmetic 烦恼,而不会让后面的所有请求都失去同步。
socket 本身位于一个只有守护进程所属用户才能读取的目录中,每条连接在解析任何东西之前都要针对同一用户进行检查。这是第二层,不是唯一的一层:任何已经以该用户身份运行的东西都可以访问 socket,所以它阻止的是共享机器上不同的用户,而不是同一用户滥用自己的守护进程。
客户端有两个独立的超时,而不是一个,因为它们回答的是两个不同的问题。约 150ms 只是用于连接,因为一个本地 socket 如果不能这么快接受连接就是不健康的,死掉的守护进程应该快速失败,而不是给每条命令都增加延迟。一旦连接建立后,再有单独的 30 秒,用于覆盖实际评估,因为自定义策略最多允许运行 10 秒,而且 Worker 一次只处理一个请求。将这两个超时合并成一个紧绷的预算,会让一个慢但正确的答案与一个死掉的守护进程无法区分,从而错误地拒绝一条根本不是问题的命令。
评估如何保持在 5ms 以下
预热 Worker 解决了最大的成本(启动运行时),但它不会自动让运行在其中的东西变快。配置仍然在每次调用时从三个文件读取,每条启用的策略仍然在每次调用时重新注册,而不是在启动时只注册一次。真正让评估保持快速的要狭窄得多:大多数策略从不接触磁盘或网络,那少数曾经需要网络的策略现在重用了答案而不是重新获取。

真正在做判断的只有两件事。首先,匹配在任何策略函数执行之前进行,所以对 Bash 的 PreToolUse 调用只会到达为该事件和该工具注册的策略。一个满是文件编辑的会话不会为那些只关心 git push 的策略付出代价。其次,只有少数策略需要真实信息(如当前所在分支),它们会进行缓存,并用廉价且正确的方式使缓存失效,而非猜测过期时间。对 .git/HEAD 修改时间做一次普通的文件 stat 就能免费告诉你分支是否发生了变化,所以实际的 git rev-parse 子进程只在真正的 checkout 之后才会再次运行,而不是每次调用都运行。
通过直接测量而非假设得出结论:调用暖工作线程调用的同一函数,用本仓库自身的真实策略集,先预热。
这些数字描述的是守护进程控制的范围:进程启动、匹配和缓存。它们并不描述任何单个策略自身的代码在开始运行后做了什么,而这部分仍然由策略作者负责。如果一次检查是对内存中已有 payload 做正则表达式或解析后 argv 比较,那么无论运行多少个这类检查,都不会超出上述时间预算。但如果检查要执行 shell 调用或网络请求,则不然,而且无论预热或缓存做多少都无法改变这一点,因为守护进程无法让别人的服务器响应得更快。
少数 Stop-time 策略正是故意这样设计的:检查 pull request 是否存在,或 CI 是否为绿色,意味着要调用 gh 并可能访问网络,因为问"现在结束是否安全"的全部意义就在于缓存答案可能是错的。这些策略每个回合只运行一次,而不是每次工具调用都运行,所以这个权衡是刻意的:把检查放在上述预算内的每条命令前面,让那个在结尾运行的检查花上它自己的下游调用实际需要的时间。
故障安全(fail closed)真正为你买来的是什么
一旦机器完成设置,守护进程无法返回答案的所有情况(socket 不可达、协议不匹配、超时)都会变成拒绝,而不是静默放行。

刻意不设置在守护进程宕机时回退到进程内评估的机制。理由写在代码里:
打破第一个就能触达第二个策略引擎并不能作为保障,一台停止一个服务就静默禁用所有护栏的机器不是一台有护栏的机器。
这才是重写的真正目的。在此之前,我们根本无法给出任何保证。现在我们可以一句话说明:在已配置的机器上,要么评估器给出答复,要么工具调用被阻止。
将会话日志发送到云端
与强制执行分开,且刻意不在处理 hook 调用的同一线程上,守护进程还运行一个小管道,追踪每个 harness 自身的会话文件并将其发送到 failproofai 云端,用于仪表盘和审计。默认关闭:需要一个显式的接入密钥,而发送实际会话内容(不仅仅是计数)需要第二次独立的 opt-in,因为记录稿可能包含提示词、文件内容以及用户在终端中粘贴的任何内容。

每个步骤的存在都对应着一种会丢失数据的替代方案。游标按文件的设备号和 inode 存储,而非路径,因为某些源通过重命名来轮转会话文件,按路径存储的游标要么会把重命名后的文件当作全新文件而重新发送全部内容,要么会把陈旧的偏移量带到新文件上而跳过其开头行。
脱敏在任何内容写入磁盘之前进行,且是一组固定模式而非模型调用:另一端的服务器按上传内容的哈希值对上传进行去重,所以相同输入每次必须脱敏到完全相同的输出,任何非确定性的东西都会破坏这一点并导致无限重复上传。
写入缓冲本身是原子的(写入临时文件、fsync、然后重命名到位),所以下游永远不会有半写入的批次被读取。有两个独立的东西来感知完成的批次:文件系统 watcher(追求速度)和周期性扫描整个目录的 sweeper(这才是真正保证送达的那个),因为错过的文件系统事件、守护进程在文件出现时离线、或者根本不报告事件的文件系统,都是真实存在的普通丢消息方式。
投递本身会重试失败的批次(原地重试而非删除),并且不盲目信任 2xx 响应:服务器可以接受请求并仍然静默跳过其中个别格式错误的行,所以会读取响应体来确认实际保留了哪些内容。这里允许同时运行的上传数量有意保持较小,远低于独立上传器会使用的数量,因为这个进程同时也是处理 hook 调用的进程,每个飞行中的上传都是本可用于工具调用决策的 CPU。
定时扫描会发送什么邮件
守护进程的审计通道(如上所述)是使扫描能够自动运行的关键:它在磁盘上维护一个挂钟截止日期(不是倒计时,因为睡眠一整夜的笔记本永远走不完倒计时),当扫描到期时,它以自己的低优先级子进程生成 failproofai audit --scheduled。那部分不需要账户且不向任何地方发送内容;结果就像你手动运行的扫描一样落在本地仪表盘上。
邮件发送扫描发现是第二次独立的 opt-in,完全在同一短生命周期子进程内发生,绝不在守护进程本身内部。

这个流程图中有两处是刻意设计的。首先,摘要范围很窄:只包含本会被实际阻止的发现,或在到达模型之前捕获了密钥的发现,而非完整扫描。其次,子进程持有登录令牌,而非守护进程,因为刷新该令牌是可检测盗窃的(使用已用过的刷新令牌会使该人的每个会话都被撤销),而且审计路径已经通过锁将一个入口点一次一个地运行。如果守护进程也持有同一个令牌,两者就必须在进程间协调,而输掉这场竞态的人会在没有任何线索的情况下被在所有地方登出。
窗口本身被跟踪为一个显式水印,而非"自本进程启动以来",并且无论服务器是否实际发送了邮件都会被写回本地。这样,服务器保留的摘要不会在下一个摘要中静默扩大范围,也不会因为某次发送未发生而导致重复计数或丢失。
这一切都不能影响它所依附的扫描。令牌过期、网络宕机,或者 api-server 状态不佳——从扫描的角度来看,这些都静默失败,因为扫描早已完成,在这些事件发生时结果已经在本地可见了。而一个从未选择加入的人,必须完全无法察觉这段代码的存在:没有网络调用,没有暗示报告曾被考虑发出的日志行。
速度。700ms 的开销只发生一次,在守护进程启动时,而不是在每次工具调用时。
真正的 fail-closed 保障,第一次出现,因为终于有东西存活得足够久,能察觉自身的缺席。
持续本地审计,支持邮件摘要如果你想要的话。一次性的进程没有办法感知一周已经过去。这个进程则持有一个时钟调度,可以无人值守地据此行动。上文已说明扫描和邮件是如何隔离的。
会话日志送达云端仪表板,而不会拖慢执行速度。与调度相同的原理:必须有东西保持存活以持有游标并重试失败的上传。上文已说明这条管道的实现方式。
集中管理的策略。组织可以推送策略更新,每台已注册的机器会在下次轮询时获取,无需重新安装。
Fail closed 从两个方向生效。守护进程或 worker 中的每个 bug 现在都会表现为一条被拒绝的命令,而不是一次静默的漏过。这正是我们想要的,但这意味着在将其设为默认选项之前,必须先修复一连串真正的 bug。以下是几个例子:
其中每一个都是通过在 Docker 中端到端运行真实的守护进程捕获的,而不是靠单元测试,因为被模拟的网络或被模拟的套接字无法复现时序 bug。
一次性进程并非坏主意,它只是缺少运行时增强无法跳过的一个属性:能够判断自己是否真的在运行。守护进程将"一个子进程可能运行过"变成了可以检查的东西,而一旦你有了一个保持存活的进程,你不妨让它一次只回答一个问题转变为做更多的事。