macOS 注销安全:结束本地代理权限
macOS 注销安全应在离开的开发者还能触发下一次调用之前,撤销保险库访问、代理审批和排队操作。

开发者注销时,必须结束本地代理权限,即使代理进程、网络请求或菜单栏应用还没有察觉。把注销当成一次礼貌的清理请求,会给无人看管的电脑留下一个窗口,让它继续使用某个人的凭据执行操作。
这对编程代理尤其重要,因为它们的实际工作会跨越权限边界。它们会调用 API、打开 SSH 会话、创建工单、发布软件包或修改基础设施。如果权限来自键盘前的那个人,那么这个人离开 macOS 会话后,权限也应随之过期。系统应该保留已发生操作的证据,但不能保留继续操作的能力。
我反复看到的错误,是把三件不同的事混在一起:本地存储的秘密、授予某个进程的审批,以及已经开始执行的操作。它们需要不同的关闭方式。一个笼统的 quit 处理器既太晚,也太模糊。
注销是权限边界,不是应用事件
macOS 注销安全意味着,没有交互式用户会话时,未来所有需要凭据的操作都必须被拒绝,不管每个应用是否都能正常退出。桌面应用可能会在正常注销期间收到终止通知,但安全性不能依赖这些通知是否送达、是否执行完毕,或是否按照某个方便的顺序执行。
用户可能会合上笔记本电脑、切换用户、强制退出应用、遭遇断电,或者在代理等待响应缓慢的端点时触发注销。操作系统也可能按照代码没有预料到的顺序拆除进程。若设计写成「我们会在 applicationWillTerminate 中撤销访问」,就已经接受了太多不确定性。
Apple 的 launchd 文档说明了这里最重要的范围区别。它区分系统、用户和图形登录域。图形用户域中的任务属于某个特定的登录会话,而系统任务拥有不同的生命周期和权限模型。不要把「进程还在运行」理解成它仍然有权代表已经离开的用户执行操作。
应围绕一个当前会话事实构建授权检查,并由操作网关在调用时验证。每个操作都必须按以下顺序询问:
- 这个已登录用户会话当前是否打开了保险库?
- 请求是否来自同一会话中一个仍然存在且获得授权的代理进程?
- 这个凭据是否要求针对本次使用进行审批?
- 注销或会话撤销事件是否已经推进了会话代数?
第四项检查可以避免一个隐蔽的竞态。请求可能通过前三项检查,进入队列,然后在注销开始后才到达执行器。在向凭据注入或 SSH 执行前再次检查代数,可以让这个过期请求失败。
不要让注销行为取决于代理是否同意停止。代理在边界上是不可信的调用方。它可能困惑、繁忙、已被攻破,或者已经消失。
保险库锁定后,必须先拒绝请求,再开始清理
保险库闸门应当先关闭,而且从执行器角度看必须同步关闭。闸门关闭后,不能再开始新的 HTTP 凭据注入,也不能开始新的 SSH 身份验证。之后可以进行清理,但清理不能成为系统变得安全的手段。
在应用维护请求队列时,这个顺序听起来很明显,实际却容易出错。典型故障是这样的:代理提交五个部署调用,界面开始注销清理,应用清除可见的会话卡片,而工作线程从队列中取出第四个调用,并使用它之前已经解析出的凭据引用。应用看起来已经注销,但操作仍然到达了外部服务。
在负责使用秘密的进程中保留一个统一的权限对象。它应包含不透明的会话标识符、代数计数器和启用状态。工作线程永远不能拿到秘密字节。它们收到的是操作请求,并且必须在保险库执行操作前立即获取新的授权租约。
一个简单的结构就够了:
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 传入的软件包名称或任意字符串,无法回答这个问题。
注销开始时,丢弃该会话的所有活跃审批。不要暂停它们,不要为下一次登录序列化它们,也不要因为相同的二进制文件重启后再次出现,就重新创建它们。开发者必须把新的运行视为一次新的运行,并重新审批。
这同样适用于已经显示在屏幕上的审批对话框。会话发生变化时,它们应消失或变为不可操作。旧卡片上的延迟点击不能重新激活已经失效的授权。为每个审批提示设置过期时间,并让它绑定到保护请求的同一个会话代数。
许多实现会混淆下面三个有用的区别:
- 解锁保险库,允许本地网关考虑执行操作。
- 会话授权,允许某个已识别的代理进程提交操作。
- 单次使用审批,允许某一次具体的凭据使用。
注销会使三者全部失效,但它们依赖的证据和时间点并不相同。保险库立即关闭。会话审批作为一个整体失效。单次使用提示则逐个失败,因为会话代数发生了变化。把它们压缩成一个布尔值,会让人难以审计某次调用为何成功或失败。
正在运行的工具需要取消机制,也需要诚实面对不确定性
注销应停止尚未跨越外部边界的工作,并尝试停止已经跨越边界的工作。但对于远程服务已经接受的操作,它无法撤销。
应将一个操作拆分为具有实际意义的状态:
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 域,确认实际启动了什么:
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 用户会话结束后,任何本地代理进程都不得再以该用户的权限发起需要凭据的操作。
其他一切都由这条规则推导出来。保险库闸门在清理前关闭。审批随着获得它的进程和会话一起失效。排队请求失去租约。已分发请求在远程系统确认结果前保持诚实的未知状态。日志继续可供审查,但秘密和执行权限不会保留。
如果产品确实需要注销后的权限,就构建一个拥有独立身份的明确无人值守服务。不要把这个决定偷偷塞进桌面应用的关闭路径里。
常见问题
锁定 Mac 屏幕和注销在代理安全方面是一回事吗?
不一样。锁屏可以防止他人随意使用控制台,但用户会话及其中的进程可能仍在运行。应将锁屏视为限制操作或要求重新审批的理由,而注销应结束本地权限,让整个会话失效。
如果代理在注销期间发起请求,应该怎么办?
应立即拒绝。保险库闸门必须先关闭,不能等延迟的关机工作、清理请求或界面动画完成。如果工具已经开始了外部操作,就记录已知状态,并阻止注销后的后续调用。
用户重新登录后,之前批准的代理还应保留审批状态吗?
不应该。审批只属于某个已登录用户会话中的某个代理进程,不属于整个账户。新的登录需要重新检查进程身份,并重新作出授权决定。
审计日志应该在 macOS 注销后保留吗?
安全的系统应保留证据,而不是保留权限。保留会话、审批、被拒调用和注销撤销操作的加密审计记录,但不要从记录中恢复可执行凭据或审批状态。
注销能安全停止正在执行的 SSH 命令或 HTTP 请求吗?
可以尝试停止,但不要把终止本地进程当成外部操作已经停止的证明。撤销前记录进程标识符、父进程、命令和操作状态。对于远程工作,应使用权限范围有限的凭据,并在服务支持时采用远程取消或过期机制。
如果按用户运行的 macOS 进程在注销后仍然存活,会发生什么?
它应该默认拒绝。即使后台进程暂时仍在运行,应用也必须把缺少交互式登录会话视为权限失败。之后的登录可以启动新的服务实例,但不能继承旧实例的授权。
开发者注销后,自主代理工作应该继续吗?
只有在用户有意构建了独立的服务权限,并为它配置独立凭据、所有者、审计记录和关闭规则时才可以继续。借用开发者本地权限的桌面代理网关,不应因为某条命令耗时较长,就悄悄变成服务器。
代理怎样使用 API 或 SSH 凭据,同时又不在注销后保留它们?
可以。如果应用在同一台 Mac 上运行并负责保管凭据,就能在注销时撤销权限,而不必把密钥放进代理进程。Sallyport 将密钥保存在加密保险库中,并自行执行 HTTP 或 SSH 操作,代理得到的是结果,而不是可重复使用的凭据材料。
本地代理权限结束时,审计日志应记录什么?
至少记录时间、用户身份、会话标识符、代理进程身份、获批权限、操作标识符、操作通道、结果和撤销原因。不要将原始请求体、响应体、令牌或 SSH 私钥材料放入广泛可读的日志。
macOS 注销适合运行长期生产代理任务吗?
不要把用户会话当作运行无人值守生产自动化的临时场所。应使用服务账户、短期远程凭据、明确的任务所有者和可审查的部署流程。开发者注销后,不应留下一个仍掌握生产权限的个人桌面。