除了 AgentGuard,我维护的另一个东西是一个很小的 Android 应用:一个按 David D. Burns 的三栏技术做的思维记录本。表面上这两个东西毫无关系——一个是给 AI 代理工具调用做的 Python 代理,一个是带滑块的笔记应用。但它们出自同一个思维习惯,而且笔记本是更干净的那个例子,因为它小到整个安全设计可以用一句话说完:这个应用够不到网络,所以它握着的东西发不出去。

这篇写的是这句话到底靠什么强制执行、它让我付出了什么,以及有一个地方它并不完全成立。

从没拿过的能力,胜过承诺不用的策略

“本应用不会上传你的数据”是一句承诺。在能看源码的时候承诺还值点钱,其他时候基本不值——而且它是安静地腐化的:1.6 版为了排查一个用户反馈加进来的崩溃上报,跟着某个不相干的库一起被传递引入的统计 SDK,写下来那天看着人畜无害的“检查更新”请求。这些都不需要任何人心怀恶意,只需要“把字节发到某个地方”的能力还摆在那里没用,等一个说得过去的理由。

Android 上有一个好得多的机制,而且它的成本是零:不要声明 android.permission.INTERNET。这个应用一共声明两个权限,都是给可选的应用锁用的:

证据 —— 已发布的 v1.5.0 APK,aapt dump badging 的输出
uses-permission: name='android.permission.USE_BIOMETRIC'
uses-permission: name='android.permission.USE_FINGERPRINT'

没有 INTERNET,创建 socket 会在内核层失败——应用的 UID 根本不在被允许开 socket 的那个组里。发起调用的是我的代码、某个库的代码,还是某个我没细看的构建插件塞进来的代码,都不影响结果。这个检查不依赖我的自律,也不会随着代码量变大而变弱。它还能被一个陌生人在四秒钟内验证,而这正是我真正在意的性质:不信任我的人,不需要去信任我。

这和 AgentGuard 的核心论点是同一个形状。在那边,结论是代理的工具权限应该被约束在协议边界上,而不是在系统提示词里好言相劝,因为“程序越不过去的边界”比“程序可以被说服的指令”值钱。在这边,边界从一个代理换成了权限清单,被约束的程序从别人的代理换成了我自己的代码。推理是一样的:先决定它被允许触及什么,再把执行点放在它无法绕过的地方。

这个决定关掉了什么

我想老实说这是有真实代价的,不是白捡的便宜——“别申请 INTERNET 就行了”这句话,在不是自己交出功能的时候特别容易讲:

这些之所以在这里可以接受,是因为这个应用的价值完全在你写进去的那些字里,而那些字恰恰是最不该动的东西。一个用“把你的笔记传到服务器”换取便利的笔记应用,是在替你做这个决定,而且通常不会写在下载页上。

逃生口是一个文件,这是故意的

数据还是得能出来——否则手机丢了就等于笔记没了,这个设计就从“保护你”变成“跟你作对”了。应用的答案是显式的 JSON 导出与导入:你按导出,你拿到一个文件,接下来怎么处理由你决定。导入是同一个形状,顺带让批量录入成为可能,关于页面上直接印着期望的格式,你可以自己手写一个。

我想要的设计性质是:每一条离开应用的路径,都要求在它发生的那一刻有一个人的动作。导出是一次点击,分享是一次点选,两者都不会在手机躺在口袋里的时候发生。而同步正相反:它只在初始设置时做过一次决定,之后便按自己的节奏一直运转——方便也正因为此,不适合这类内容也正因为此。

我留下的那个缺口

有一条出去的路不是我写的,但它确实还在:应用的备份规则包含了它的数据库,所以如果你开着 Android 自带的备份,你的记录会跟手机上其他东西一起,有一份副本进到你的 Google 账号里。

证据 —— 已发布版本里的 res/xml/data_extraction_rules.xml
<data-extraction-rules>
    <cloud-backup>
        <include domain="database" path="." />
    </cloud-backup>
</data-extraction-rules>

Android 9 及以后,系统备份是用设备凭据做端到端加密的,所以这跟“把明文发到我控制的服务器”不是一回事。但它确实是设备之外的存储,而且它的发生不需要每次都有一个人的动作——而这恰恰是我上一节刚宣称这个设计具备的性质。是写这篇文章让我重新打开这个文件,才看见这个矛盾。

老实的账是这样算的:这是项目模板给我的默认值,留着是因为“丢了手机不该等于丢掉几个月的记录”,然后就再也没有拿应用其他部分遵守的那条规则去重新审视它。前后一致的版本,是把数据库从云备份里排除掉,让显式导出成为唯一的恢复途径——这对把手机掉进河里的人来说是稍微糟一点的一天,但和页面上的承诺更对得上。这是我接下来会改的地方;在改完之前,应用页面上直接写着这个例外,而不是留给别人去发现。

泄露发生在屏幕上,不只在磁盘上

最后一块,因为存储只是其中一个面。可选的应用锁用指纹、面容或设备密码;锁定期间,应用在“最近任务”里的预览会被隐藏,截屏也被禁止。

这件事之所以重要,是因为这个应用现实中的威胁模型根本不是远程攻击者。它是有人拿起一台没锁屏的手机,是截图时无意中把背后的一条思维记录一同截下,是“最近任务”的卡片把你上一个界面展示给站在你旁边的人。远程那一半已经被“缺失的权限”从结构上解决了;剩下的是本地这一半,那就在本地泄露实际发生的表面上解决。

更一般的说法

我维护的这两个东西,底下是同一个主张:一个程序应该刚好能触及它的工作需要的东西,别的都碰不到;而这个限制应该放在程序无法绕过的地方——一个它必须穿过的代理,或者一个它从来没被授予的权限。笔记本是这个论点小到可以用一条命令验证的版本;AgentGuard 是被约束的程序换成了一个 AI 代理、而它的行为连训练它的人都无法完全预测的版本。

如果要把这套东西用到别处,我想强调的一点是:机制的价值等于它关掉了什么。去掉一个你本来就不打算用的能力,那是表演。去掉一个你确实想要的——同步、崩溃上报、使用数据——才是重点,这也是为什么上面那份代价清单比收益清单更长。