# macOS 注销安全：结束本地代理权限

开发者注销时，必须结束本地代理权限，即使代理进程、网络请求或菜单栏应用还没有察觉。把注销当成一次礼貌的清理请求，会给无人看管的电脑留下一个窗口，让它继续使用某个人的凭据执行操作。

这对编程代理尤其重要，因为它们的实际工作会跨越权限边界。它们会调用 API、打开 SSH 会话、创建工单、发布软件包或修改基础设施。如果权限来自键盘前的那个人，那么这个人离开 macOS 会话后，权限也应随之过期。系统应该保留已发生操作的证据，但不能保留继续操作的能力。

我反复看到的错误，是把三件不同的事混在一起：本地存储的秘密、授予某个进程的审批，以及已经开始执行的操作。它们需要不同的关闭方式。一个笼统的 `quit` 处理器既太晚，也太模糊。

## 注销是权限边界，不是应用事件

macOS 注销安全意味着，没有交互式用户会话时，未来所有需要凭据的操作都必须被拒绝，不管每个应用是否都能正常退出。桌面应用可能会在正常注销期间收到终止通知，但安全性不能依赖这些通知是否送达、是否执行完毕，或是否按照某个方便的顺序执行。

用户可能会合上笔记本电脑、切换用户、强制退出应用、遭遇断电，或者在代理等待响应缓慢的端点时触发注销。操作系统也可能按照代码没有预料到的顺序拆除进程。若设计写成「我们会在 `applicationWillTerminate` 中撤销访问」，就已经接受了太多不确定性。

Apple 的 `launchd` 文档说明了这里最重要的范围区别。它区分系统、用户和图形登录域。图形用户域中的任务属于某个特定的登录会话，而系统任务拥有不同的生命周期和权限模型。不要把「进程还在运行」理解成它仍然有权代表已经离开的用户执行操作。

应围绕一个当前会话事实构建授权检查，并由操作网关在调用时验证。每个操作都必须按以下顺序询问：

1. 这个已登录用户会话当前是否打开了保险库？
2. 请求是否来自同一会话中一个仍然存在且获得授权的代理进程？
3. 这个凭据是否要求针对本次使用进行审批？
4. 注销或会话撤销事件是否已经推进了会话代数？

第四项检查可以避免一个隐蔽的竞态。请求可能通过前三项检查，进入队列，然后在注销开始后才到达执行器。在向凭据注入或 SSH 执行前再次检查代数，可以让这个过期请求失败。

不要让注销行为取决于代理是否同意停止。代理在边界上是不可信的调用方。它可能困惑、繁忙、已被攻破，或者已经消失。

## 保险库锁定后，必须先拒绝请求，再开始清理

保险库闸门应当先关闭，而且从执行器角度看必须同步关闭。闸门关闭后，不能再开始新的 HTTP 凭据注入，也不能开始新的 SSH 身份验证。之后可以进行清理，但清理不能成为系统变得安全的手段。

在应用维护请求队列时，这个顺序听起来很明显，实际却容易出错。典型故障是这样的：代理提交五个部署调用，界面开始注销清理，应用清除可见的会话卡片，而工作线程从队列中取出第四个调用，并使用它之前已经解析出的凭据引用。应用看起来已经注销，但操作仍然到达了外部服务。

在负责使用秘密的进程中保留一个统一的权限对象。它应包含不透明的会话标识符、代数计数器和启用状态。工作线程永远不能拿到秘密字节。它们收到的是操作请求，并且必须在保险库执行操作前立即获取新的授权租约。

一个简单的结构就够了：

```text
AuthorityState {
  sessionID: 6C17...
  generation: 41
  vaultOpen: true
  logoutStarted: false
}

execute(request):
  lease = authority.issueLease(request, generation: 41)
  vault.perform(request, lease)

beginLogout():
  authority.logoutStarted = true
  authority.vaultOpen = false
  authority.generation = 42
  cancelPendingRequests()
```

如果租约的代数不再匹配，`vault.perform` 必须拒绝它。第二次比较应尽可能靠近保险库提供 HTTP 请求头、启动 SSH helper 或签署请求的位置。只在请求进入队列时检查，会留下足以造成影响的竞态窗口。

对于使用 Secure Enclave 和 Touch ID 保护保险库的 macOS 应用，硬件控制的访问很有用，因为它为闸门提供了明确的本地所有者。但这并不能免除会话状态的必要性。注销前接受的生物识别提示，不能授权注销后的调用。

Sallyport 的保险库闸门遵循这一规则：锁定时，所有操作一律拒绝。这个固定边界优于关闭例外列表，因为例外列表会不断扩大，最后没人能说清楚还有哪些调用可以绕过它们。

## 活跃审批只属于一个进程和一个会话

用户审批应绑定到特定的代理进程、其代码签名权限，以及当前的 macOS 登录会话。它绝不能表示「这个账户以前某个时候批准过这个工具」。

进程身份不只是进程 ID。进程 ID 会被重新使用。只记录 PID 4812 的调用方，经过足够多次进程变化后，可能会意外为无关进程授予权限。应在 PID 之外记录进程启动时间和代码签名身份。如果进程是终端或编辑器集成的子进程，应保留足够的父进程信息，以便在审批记录中解释调用路径，但不要把进程祖先关系作为唯一的信任信号。Shell wrapper 和进程监管器会不断改变这条关系。

审批卡片应优先显示签名权限，因为这能让开发者面对一个有意义的问题：「我是否希望这个已签名的代理进程在当前会话中执行操作？」通过 MCP 传入的软件包名称或任意字符串，无法回答这个问题。

注销开始时，丢弃该会话的所有活跃审批。不要暂停它们，不要为下一次登录序列化它们，也不要因为相同的二进制文件重启后再次出现，就重新创建它们。开发者必须把新的运行视为一次新的运行，并重新审批。

这同样适用于已经显示在屏幕上的审批对话框。会话发生变化时，它们应消失或变为不可操作。旧卡片上的延迟点击不能重新激活已经失效的授权。为每个审批提示设置过期时间，并让它绑定到保护请求的同一个会话代数。

许多实现会混淆下面三个有用的区别：

- 解锁保险库，允许本地网关考虑执行操作。
- 会话授权，允许某个已识别的代理进程提交操作。
- 单次使用审批，允许某一次具体的凭据使用。

注销会使三者全部失效，但它们依赖的证据和时间点并不相同。保险库立即关闭。会话审批作为一个整体失效。单次使用提示则逐个失败，因为会话代数发生了变化。把它们压缩成一个布尔值，会让人难以审计某次调用为何成功或失败。

## 正在运行的工具需要取消机制，也需要诚实面对不确定性

注销应停止尚未跨越外部边界的工作，并尝试停止已经跨越边界的工作。但对于远程服务已经接受的操作，它无法撤销。

应将一个操作拆分为具有实际意义的状态：

```text
queued -> authorized -> dispatched -> response received
                    \-> cancelled
```

排队中的操作尚未离开 Mac。将它从队列中移除，并报告 `cancelled_before_dispatch`。已授权的操作可能只持有一个短期内部租约。在分发前使租约失效；如果工作线程太晚才执行到这里，就报告 `revoked_before_dispatch`。

已分发的操作不同。远程系统可能已经收到它，即使本地进程从未收到响应。不要因为关闭套接字或杀掉 helper，就把它报告为已取消。记录 `logout_during_dispatch`，如果远程协议提供请求标识符，就一并记录，并告诉用户：在检查远程系统之前，结果仍然未知。

HTTP 请求需要特别小心。关闭客户端连接，可能会在服务器读取上传内容前停止上传，也可能发生在服务器已经提交变更之后。幂等令牌可以在用户之后重试时减少损害，但不能把不确定的请求变成已取消的请求。对于会创建外部资源的操作，应发送远程 API 实际支持的幂等标识符，并记录这个标识符，但不要存储凭据。

SSH 更难处理。向本地 helper 发送信号，可能只会杀掉本地进程，而远程命令仍在自己的进程组中继续运行。如果你控制远程环境，应让长期任务由远程任务监管器运行，并提供明确的任务标识符和取消路径。如果你不控制远程环境，就在活动记录中说明这一点。因为本地终端关闭，就声称远程迁移已被取消，只会让事故变得更糟。

不要让注销无限期等待清理。先关闭权限，再要求工作线程取消，给应用一个短暂且有上限的清理时间，然后让操作系统完成注销。安全属性是拒绝未来使用，优雅退出只是尽力提供的便利。

## 后台持久化会改变威胁模型

如果一个按用户运行的应用在用户注销后仍继续操作，它就从桌面助手变成了无人值守服务。对于有意设计的服务账户，这可能是合理的；对于菜单栏应用的意外副作用，则不合理。

不要为了让代理在注销后继续运行，就安装特权 helper 或系统域 launch 任务。这样做很受欢迎，因为它让长期任务看起来更可靠。但它也会让操作路径脱离批准操作的人，并且往往把访问范围扩大到原始用户会话之外。

如果团队确实需要在开发者离开后继续工作，就为这类工作提供独立的归属。使用明确的服务身份、范围有限且会过期的远程凭据、定义清晰的所有权、审计记录，以及其他操作人员可以使用的取消流程。让交接过程可见。本地代理不应默默继承这个角色。

快速用户切换也暴露出同样的问题。用户 A 可能让图形会话保持打开，然后用户 B 登录。用户 B 不能审批或观察用户 A 的代理权限。应将每个网关实例、审批记录和保险库访问检查绑定到正确的用户和图形会话。让一台机器范围的守护进程随意复用两个用户，需要非常严格的隔离，大多数桌面工具都应避免这种架构。

睡眠不是注销。处于睡眠状态的 Mac 可能恢复同一个用户会话，因此团队需要单独决定睡眠和锁屏时的行为。对于敏感凭据，在锁屏时关闭保险库通常很合理。对于不太敏感的本地工作，网关可以保留保险库状态，但要求唤醒后重新审批。无论选择什么策略，都不要把它称为注销行为。用户和事故审查人员需要准确的术语。

## 日志应比权限活得更久，但不能变成第二个秘密仓库

尤其是在网络操作与注销重叠时，你需要一份持久记录，说明已经结束的权限。你不需要另一个塞满令牌、请求体或 SSH 私密材料的数据库。

将生命周期事件作为事实写入日志：会话打开、代理进程获批、请求提交、凭据使用获批、开始分发、观察到注销、租约撤销、请求终止 helper，以及最终结果。加入稳定标识符，方便操作人员关联这些事件，但要尽量减少用户内容。活动记录可以说明某次 HTTP 调用访问已配置的端点并成功，而不必保留授权请求头或敏感响应体。

哈希链式审计日志具备普通应用日志没有的属性：离线验证器可以发现记录是否被删除或修改。这对于争议部署或疑似本地入侵后的调查很有用，但它不会凭空让日志变得真实。日志只能证明其中记录之间的连续性，不能证明恶意进程在采取行动前从未停止记录。

因此，应在任何尽力而为的进程清理之前写入撤销事件。如果应用在杀掉 helper 时崩溃，记录仍应显示本地权限已经结束，而操作结果可能不确定。只记录干净完成的审计轨迹，会给操作人员造成错误印象。

Sallyport 将会话日志和活动日志都写入同一个只写不可改的加密哈希链式审计日志，`sp audit verify` 可以在密文上离线检查哈希链。这样，团队无需打开保险库，就能检查注销是否撤销了某次运行以及日志是否连续。

有用的审计问题应当具体：「哪个进程拥有权限，它请求了哪条凭据路径，以及用户离开时网关知道什么？」一份庞大的调试日志通常回答不了这些问题。

## 测试竞态，而不是只测试干净退出

注销测试必须让操作与边界重叠。等所有工作线程结束后再调用应用关闭方法，只能验证正常清理是否有效。

先准备一个你能控制的端点，让它接收请求、记录收到请求的时间，然后延迟响应。通过代理网关提交操作，并在网关已经将其标记为已分发、但响应尚未返回时触发注销。下次登录后，将本地审计状态与端点记录进行比较。预期结果不一定是「已取消」，而应是准确的状态，例如 `logout_during_dispatch`，并附上可以继续调查的请求标识符。

为排队中的工作单独编写测试。让工作线程在取出队列项目后暂停，但在向保险库请求操作租约之前暂停。开始注销，然后释放工作线程，并断言它得到的是撤销结果，而不是发送请求。这个测试可以发现一个常见错误：只在请求进入队列时检查授权。

然后测试这些令人不舒服的情况：

- 在原始会话仍保持打开时切换到另一个用户。
- 锁定屏幕、唤醒机器，并测试你为这一转换选择的策略。
- 在请求等待审批时强制退出代理，然后启动一个 PID 被重新使用的新进程，只要测试工具能够安排这种情况。
- 在网关关闭期间中断它，并检查审计记录是否仍能识别未解决的工作。
- 发送一个会启动远程工作的 SSH 命令，然后确认远程端行为，不要只相信本地 helper 的退出状态。

在 macOS 上，应在测试准备期间检查 launch 域，确认实际启动了什么：

```sh
uid="$(id -u)"
launchctl print "gui/$uid" | grep -E "(agent-gateway|your-test-label)"
```

输出会因已安装的任务和 macOS 版本而异，但应显示当前 `gui/<uid>` 域中的匹配任务。如果测试任务出现在系统域中，那么测试结果对普通桌面应用的会话行为几乎没有说明力。

不要一开始就针对开发者的主账户自动执行真实注销。使用一次性本地账户、一次性 API 凭据和一个可以检查每个请求的端点。注销测试可能破坏未保存的工作，也可能让远程状态停留在半完成状态。这不是跳过测试的理由，而是不能把它们当成普通单元测试的理由。

## 远程凭据应限制延迟调用的影响范围

本地撤销无法穿越时间，撤销远程服务已经接受的 bearer token。远程凭据的设计可以在网关发现请求太晚、应用崩溃，或机器在注销前被攻破时限制损害。

在目标系统支持的情况下，优先使用权限范围有限且生命周期较短的远程凭据。为不同环境使用不同凭据。代理可以更新预发布部署，并不意味着它应该拿到能够删除生产数据的凭据，只因为两个端点恰好使用同一家 API 厂商。

对于 SSH，应使用专用远程账户，并限制该账户可以执行的操作。如果某条命令必须启动长期任务，应让任务的所有权和取消方式在远程端清晰可见。在本地父进程消失后仍需要解释某个任务为何继续运行时，拥有广泛 shell 权限的个人 SSH 身份会立刻变得不方便。

不要通过环境变量、配置文本、占位符替换或 shell 参数将凭据传给代理。秘密一旦进入调用方，注销可以阻止未来的本地操作，却无法让副本从进程内存、shell 历史、崩溃报告或记录中消失。网关应只将凭据注入它自己执行的操作，并将结果返回给代理。

这样，注销就有一个清晰的任务：关闭保险库、使活跃授权失效、拒绝过期租约、停止待处理工作，并为已经发送的工作记录不确定性。它不能承诺撤销互联网中的一切，但可以阻止下一次调用借用一个已经离开的人的权限。

## 退出条件应该容易解释

把规则写成一句疲惫的开发者在事故中也能使用的话：当 macOS 用户会话结束后，任何本地代理进程都不得再以该用户的权限发起需要凭据的操作。

其他一切都由这条规则推导出来。保险库闸门在清理前关闭。审批随着获得它的进程和会话一起失效。排队请求失去租约。已分发请求在远程系统确认结果前保持诚实的未知状态。日志继续可供审查，但秘密和执行权限不会保留。

如果产品确实需要注销后的权限，就构建一个拥有独立身份的明确无人值守服务。不要把这个决定偷偷塞进桌面应用的关闭路径里。
