# 重复的网关实例会分裂你的审计轨迹吗？

复制应用 bundle 并不是创建第二个本地网关的无害方式。它测试的是，当 macOS 同时呈现两个看似合理的请求方时，软件能否继续让凭据、批准、撤销和证据归属于同一个权威来源。

危险的失败不一定是崩溃。崩溃很明显。更隐蔽的失败是两个进程看起来都很健康，都接受智能体请求，也都留下足够的证据，让启动它们的人感到放心。随后，凭据变更、撤销操作或事件复盘暴露出分裂：一个进程知道某件事，另一个却不知道。

Sallyport 的设计是一款经过签名、持续运行的 macOS 菜单栏应用，保险库核心在进程内运行。因此，同时启动稳定版、Beta 版和复制的 bundle，应当被视为有意进行的并发与身份测试，而不是正常操作。测试应当得到一个明确而有限的答案：系统会拒绝副本，安全地协调副本，还是允许副本运行，同时仍保留一个权威保险库和一份可验证的历史记录？

## 两个 bundle 不会自动代表两个身份

在这个实验中，Finder 中显示的名称是最弱的身份信号。`Sallyport.app`、`Sallyport Beta.app` 和 `Sallyport Copy.app` 对人来说可能像三个独立应用，但它们的 bundle 中仍可能带有相同的 Bundle ID 和相同的签名身份。

Apple 将 Bundle ID 说明为 macOS 用于应用级识别的标识符，其代码签名资料则解释了 designated requirement 如何帮助系统在更新前后识别同一个应用。这些概念很有用，但都不能直接回答这里真正关心的问题。macOS 能识别身份，并不代表两个运行中的进程能够安全地拥有状态。

先记录你实际启动了什么。在打开保险库、连接智能体或批准任何操作之前完成这一步。

```sh
APP_A="/Applications/Sallyport.app"
APP_B="$HOME/Desktop/Sallyport Beta.app"

for app in "$APP_A" "$APP_B"; do
  echo "=== $app ==="
  plutil -p "$app/Contents/Info.plist" | grep -E 'CFBundleIdentifier|CFBundleShortVersionString|CFBundleVersion'
  codesign -dvv "$app" 2>&1 | grep -E 'Identifier=|TeamIdentifier=|Authority='
  codesign -d -r- "$app" 2>&1 | grep 'designated =>'
done
```

输出的形状比具体文字更重要：

```text
=== /Applications/Sallyport.app ===
"CFBundleIdentifier" => "..."
"CFBundleShortVersionString" => "..."
Identifier=...
TeamIdentifier=...
designated => identifier "..." and anchor ...
```

把这份结果与测试记录放在一起。如果两个完整 bundle 报告了相同的标识符和 designated requirement，就把它们称为同一个代码身份的两个副本。如果不同，就把它们称为不同的代码身份。不要用「稳定版」和「Beta 版」这样的标签代替这些事实。

还要区分另一个经常被混淆的概念：代码身份不等于存储身份。两个拥有相同 designated requirement 的进程，可能应该读取同一份受保护材料。拥有不同 requirement 的两个进程，可能获得不同的系统权限。但这两种结果都不能说明它们能否安全地共享加密保险库，或向同一条审计链追加记录。存储所有权必须单独测试。

## 分裂的审计轨迹比不完整日志更糟

不完整的日志会告诉你证据缺失。分裂的日志则可能讲述两个内部自洽、却各自缺少另一方记录的故事。这会拖慢事件复盘，也可能让已撤销的会话从某个进程的角度看起来仍然有效。

对于操作网关，日志事后需要回答一些具体问题：

- 哪个智能体进程请求了这项操作？
- 哪个网关进程批准或拒绝了它？
- 使用了哪个凭据引用，同时没有暴露密钥本身？
- 人工批准的是整个会话，还是单次调用？
- 撤销发生在操作之前还是之后？

如果进程 A 和进程 B 各自写入独立序列，那么两个序列可能分别验证通过。这还不够。验证加密链只能说明该链内部的记录没有在不被发现的情况下遭到修改。它本身不能证明另一个合法写入者没有在别处启动另一条独立链。

因此，必须区分防篡改证据和完整性。哈希链保护的是该链中现有记录之间的关系。完整性则需要明确的所有权规则、唯一追加点，或能够持久记录每个已接受分支及其协调方式的证据。如果这里做错了，操作员可能顺利完成验证，却仍然漏掉一半操作。

问题不只存在于日志。审计轨迹分裂通常伴随着权限分裂：

- 一个进程认为保险库已锁定，另一个进程却拥有已解锁的会话。
- 一个进程撤销了智能体运行，另一个进程仍然接受它。
- 一个进程记录了单次调用批准，另一个进程却认为没有理由询问。
- 一个进程写入最终操作结果，另一个进程只写入请求。

不要用两个副本能否完成 HTTP 调用或 SSH 命令来判断测试结果。调用成功只能证明某条路径存在。只有当之后的复核者无需猜测缺失状态究竟由哪个副本持有，就能还原完整的操作历史时，测试才算通过。

## 在开始竞争前确定所有权约定

没有预期不变量的重复实例测试，只会产生零散故事。先写下约定，再尝试破坏它。

本地网关副本有三种站得住脚的约定。

1. **独占所有权。** 第一个进程拥有保险库和日志。后续副本拒绝运行、将第一个应用置于前台，或带着明确原因退出。
2. **单个活动进程并支持交接。** 后续启动会发现当前所有者，并请求它执行所需工作。新进程不会自行解锁、批准或追加记录。
3. **协调式多进程所有权。** 多个进程可以运行，但它们都使用有意设计的共享状态协议，保证保险库变更具有原子性，并保持一条可审计的事件顺序。

对于持有高影响状态的桌面应用，第一种约定通常最容易推理。第三种也可能有效，但需要承担大得多的证明负担。「它们都指向同一个文件夹」不是协调协议。

测试前先用表格写下预期结果。每个结果都必须能够观察到。

| 条件 | 预期行为 | 应保留的证据 |
| --- | --- | --- |
| 稳定版拥有已解锁的保险库 | Beta 启动被拒绝、交接，或进入协调流程 | 界面状态、进程列表、网关响应 |
| 稳定版拥有已锁定的保险库 | 保险库闸门打开前，任何副本都不能执行操作 | 拒绝请求记录和本地状态 |
| 稳定版拥有已批准的智能体会话 | 新的智能体进程需要自己的会话决定 | 带进程身份的批准记录 |
| 某凭据设置为每次调用都需批准 | 无论哪个副本收到请求，每次使用都询问 | 每次尝试调用对应的一条批准记录 |
| 一个进程撤销了某次运行 | 任何进程都不能继续该运行 | 撤销事件和后续调用被拒记录 |
| 两个副本同时请求无害操作 | 历史保持完整并能通过验证 | 有序活动记录和审计验证结果 |

措辞很重要。「副本不应冲突」无法测试。「已有所有者撤销某个智能体运行后，复制的 bundle 不能再返回成功的操作响应」则可以测试。

还要把产品行为和测试工具行为分开。Shell 脚本可以防止你意外启动两个进程，但这不能证明应用本身阻止了它们。反向代理可以将请求串行化，但这不能证明本地保险库能处理同时到达的请求。不要让测试工具替你解决本来要发现的缺陷。

## 先启动完整副本，再测试修改过的构件

干净的实验应从完整的应用 bundle 开始。在不修改内部文件的情况下复制应用 bundle，将副本放到不同的普通位置，并记录每个路径。先修改可执行文件或 `Info.plist`，通常会使代码签名失效，这会把实验变成签名验证测试。

Apple 的 TN3127 解释了这一边界为何重要：macOS 依赖 designated requirement 判断代码是否符合已建立的身份。如果你修改了 bundle，签名不再有效，那么任何拒绝都可能是正确的，但这无法告诉你有效构建之间是否存在重复所有权。

使用一份简短的启动工作表，只记录能够观察到的事实：

```text
Test ID: duplicate-owner-01
Build A path: /Applications/Sallyport.app
Build B path: /Users/tester/Desktop/Sallyport Beta.app
Bundle IDs: [recorded value A] / [recorded value B]
Designated requirements: [recorded value A] / [recorded value B]
Launch order: A first, B second
Vault state before launch: locked
Agent process IDs: [record after start]
Expected contract: exclusive ownership
```

然后启动 A，等待它进入空闲状态，再启动 B。立刻记录结果，不要先做其他事情。拒绝必须明确到足以让操作员知道哪个已有进程拥有这些状态。无声退出会制造支持工单，也会诱使人们反复重试，直到真正触发竞争条件。

通过操作系统确认进程确实分离，而不是只看应用窗口。第二个窗口可能属于同一个进程，而后台辅助进程也可能创建第二个进程，即使屏幕上只有一个菜单栏项目。

```sh
pgrep -alf 'Sallyport|sp mcp|sp-ssh'
ps -axo pid,ppid,start,command | grep -E 'Sallyport|sp mcp|sp-ssh' | grep -v grep
```

把输出当作时间线证据。每个启动阶段后立即保存。尽可能包含父进程 ID。它们有助于解释第二次操作来自独立的应用进程、由智能体启动的 shim，还是子辅助进程。

不要使用生产凭据进行这项工作。让 HTTP 请求指向只接受无害方法的专用端点或受控测试服务。对于 SSH，使用带命令限制的专用账户，或使用无法访问生产环境的主机。你测试的是进程所有权和审计行为，不需要破坏性端点来判断第二个副本是否能够执行操作。

## 保险库闸门必须有一个可见的所有者

只有当每条操作路径都经过同一个闸门时，保险库闸门才是绝对边界。如果一个进程在另一个进程锁定、退出或失去访问权后仍保持解锁，那么「已锁定」描述的只是窗口，而不是实际系统状态。

先用无害的 HTTP 操作测试以下流程：

1. 在保险库锁定的情况下启动副本 A。
2. 通过本地 MCP shim 连接一个智能体进程，并尝试无害操作。预期结果应为拒绝。
3. 通过正常的本地交互解锁 A，然后再次执行操作。记录成功结果及其活动条目。
4. 在 A 仍然可用时启动副本 B。暂时不要解锁 B。
5. 让同一个智能体进程和一个全新的智能体进程，分别通过 B 可访问的路径尝试相同操作，前提是存在这样的路径。
6. 锁定 A 或退出 A，然后让两个智能体再次发送请求。

通过标准取决于所有权约定，但不安全的结果很容易描述：B 执行了操作，因为它保留了某种权限，或独立获得了人类锁定 A 时看不见的权限。

这里有一条常见建议会失效：「直接共享已解锁状态，这样 Beta 就不用反复询问。」开发时本地认证反复出现确实令人烦恼，所以人们会提出这个建议。但除非共享状态有明确所有者、清晰的生命周期，以及每个进程都会在执行前观察到的撤销路径，否则这种做法是错误的。为了省掉一次提示，不值得制造一个看不见的第二解锁状态。

硬件保护的保险库模型提供了一个有用的测试边界。保险库锁定时，智能体应该收到拒绝，而不是占位密钥、部分准备好的请求，或解锁后才执行的排队操作。操作网关应当自行执行已批准的操作并返回结果，不应在等待人工处理本地状态时把凭据材料交给智能体。

同时记录操作响应和日志证据。一个从历史中消失的拒绝会让之后的调试变得不必要地困难。没有先前解锁或批准记录却出现成功，更加危险。

## 会话批准必须绑定进程，而不是标签

会话级授权的目的，是让人判断某一次具体的智能体运行。当授权因为两个运行具有相似名称、来自同一个终端，或通过同一个复制 bundle 连接，而从一个进程扩散到另一个进程时，它就失去了意义。

批准记录应当让操作员能够回答：哪个可执行文件发起了请求，谁签署了它，该进程何时开始运行，以及它的权限何时结束？进程的代码签名权限尤其有用，因为单独的一条终端命令无法识别其背后的代码。

用两个有意分开的智能体进程运行这项测试。不要复用一个 Shell 进程，却把它称为两个智能体。

```sh
# Terminal 1
sp mcp

# Terminal 2
sp mcp
```

命令本身刻意保持简单。重要的是，它们来自测试工具中的两个独立智能体进程，然后各自发送一次无害调用。批准第一个会话，让第二个保持未处理状态。如果你的设置会在批准卡片中显示进程详情，就截取显示的权限，并与启动的进程进行比较。

现在尝试以下状态转换：

- 退出已批准的智能体进程，然后用相同命令启动替代进程。
- 在第一个会话仍获批准时，启动或激活复制的网关。
- 从会话日志中撤销第一次运行。
- 让两个进程各自再发出一次无害请求。

预期规则很简单：批准在该运行退出前只属于该运行，撤销则在所有地方结束该运行的权限。替代进程不能仅仅因为看起来熟悉，就继承批准。复制的网关也不能把同一个智能体连接解释为绕过其对等进程本应要求的决定的许可。

不要把会话批准和每次调用密钥混为一谈。会话批准回答的是，这个智能体进程在本次运行中是否可以使用网关。每次调用密钥回答的是，每次使用某个特定凭据是否都需要新的人工决定。前者关注调用者的生命周期，后者关注凭据的敏感程度。如果在测试记录中把两者合并，你会误读结果。

## 每次调用批准会暴露隐藏的重复路径

每次使用都需要批准的凭据，是探测重复实例的好工具，因为它迫使软件分别处理每一次操作。使用一个专用测试凭据或 SSH 目标，将其设置为每次调用都需批准，然后通过两条候选路径发送同时发生的无害请求。

测试重点不是「我是否看到了两个提示」。两个提示可能是正确行为，也可能说明两个副本创建了独立权限。真正的问题是，每个完成的操作是否都有一条对应批准，以及某个副本能否复用另一个副本的批准。

测试时建立一份简短的事件台账：

| 时间 | 网关进程 | 智能体 PID | 请求 ID | 人工决定 | 结果 |
| --- | --- | --- | --- | --- | --- |
| 10:03:01 | A | 4128 | req-a1 | 已批准 | 成功 |
| 10:03:02 | B | 4194 | req-b1 | 已拒绝 | 已拒绝 |
| 10:03:04 | A | 4128 | req-a2 | 已批准 | 成功 |

使用测试端点或测试工具生成的请求 ID。不要依赖窗口出现的顺序。桌面调度可能会重新排列可见提示，HTTP 结果也可能在更晚发出的请求之后才返回。

糟糕的结果起初往往看起来很整齐：在副本 A 中批准请求 A，然后发现副本 B 可以在没有自己批准的情况下完成请求 B。这说明批准状态越过了边界，却没有经过操作员决定。反过来的失败同样严重：批准了 B，但结果却记录在 A 的活动流中，而且没有解释 A 为什么执行了操作。

在锁定保险库、退出一个副本以及撤销第一次智能体运行后，重复进行竞争测试。这些状态转换能发现内存中的旧状态。最有价值的缺陷通常出现在状态改变之后，此时一个进程已经刷新视图，另一个还没有。

## 审计验证需要前后两份证据

「日志看起来没问题」不是测试证据。先捕获基线，创建一组受控操作，在运行后进行验证，然后将预期记录与实际记录比较。

这个网关的审计设计有一个优点：同一份不可读写的加密哈希链日志，同时投影到 Sessions 日志和 Activity 日志。因此，这两个日志应当是同一段历史的两个视图，而不是两份各自维护、只是看起来相似的记录。

从空的测试期或边界清晰的测试期开始。记下开始时间，然后生成一组已知序列：锁定时拒绝、已批准会话操作、每次调用批准的操作、每次调用拒绝、会话撤销、撤销后操作被拒绝。序列要足够短，确保可以核对每一条事件。

之后运行离线验证器：

```sh
sp audit verify
```

保留完整的命令输出，包括失败时的非零退出状态。验证器不应需要访问保险库才能检查密文链。这一特性让调查人员无需先解锁凭据存储就能验证完整性，而当重复进程测试出现问题时，这正是你需要的能力。

验证只是第一步。比较以下三个视图：

1. 外部测试台账，包括每个请求 ID 和预期结果。
2. Sessions 日志，包括批准、运行退出和撤销。
3. Activity 日志，包括每次操作尝试及其结果。

台账中的每个事件都应映射到相应的日志视图。测试时间范围内的每个日志事件，也都应能映射回某个你有意生成的事件。即使哈希链验证通过，也要调查额外记录。意外成功的操作可能是重试、排队请求，也可能说明第二个进程在你以为它已退出后仍然处于活动状态。

如果两条独立链都验证通过，除非文档化的多进程设计记录了父子关系并提供确定性的合并机制，否则应将其报告为历史完整性失败。不要在测试后通过拼接导出的日志解决问题。手工合并能生成报告，却不能生成审计轨迹。

## 恢复行为决定重复实例缺陷是否会变成事件

你应当预料到所有权尝试会被中断。人们会强制退出 Beta 版本，笔记本电脑会休眠，测试工具也可能在批准和操作结果之间杀死进程。应用必须处理这些情况，既不能让被遗弃的所有者永远阻塞工作，也不能让替代所有者假设什么操作都没有发生。

至少测试四个中断点：

- 副本 A 收到请求后、获得批准前被杀死。
- 副本 A 获得批准后、外部操作完成前被杀死。
- 副本 A 完成操作后、结果对智能体可见前被杀死。
- 每次中断后立即启动副本 B。

每种情况都应在执行前写下安全恢复规则。一种合理规则可能是，B 必须先确认 A 已退出并恢复持久状态，否则拒绝运行。另一种规则可能是，B 只能从已提交的审计记录恢复，并将任何不确定的操作标记为未知，而不是宣布成功或失败。正确规则取决于具体实现，但绝不能靠猜。

不要掩盖不确定性。外部系统可能在本地进程刚刚退出时完成 HTTP 请求或 SSH 命令。如果网关无法证明操作是否完成，就应当在证据中保留这种模糊状态。重试一个未知的写操作，可能在现实世界中再次执行同一操作。假装第一次没有发生，则会制造虚假的审计故事。

即时撤销也要在这里仔细检查。撤销一个智能体运行，紧接着终止当前所有者，启动一个副本，再让该智能体尝试同一操作。如果替代进程把缺失的内存状态当成新会话，说明撤销还不够持久。只有在某个进程仍然存活时才有效的撤销，不是失败期间可以依赖的撤销。

## 除非有意共享，否则隔离稳定版和 Beta 测试

稳定版和 Beta 版都很有用，因此团队很容易让它们随意共存。稳定版承载真实工作，Beta 版需要真实压力。让两者在没有明确兼容约定的情况下共用保险库和日志，会带来生产环境的风险，却只有实验环境的可观测性。

使用以下两种安排之一。更安全的安排是给 Beta 提供专用测试保险库、测试凭据、测试智能体和独立证据集。这样可以测试 Beta 的行为，同时防止实验进程成为生产操作记录的一部分。

更复杂的安排是让稳定版和 Beta 版有意共享状态。只有当跨版本连续性本身就是测试对象时，才选择这种方式。此时要双向测试版本差异：稳定版拥有状态后启动 Beta，Beta 拥有状态后启动稳定版，一个进程升级而另一个仍保持打开，以及一个进程回滚而另一个已经写入新历史。记录每次转换是被拒绝、交接还是协调完成的。

对于 Sallyport，固定的决策顺序提供了有用的验收框架：保险库闸门始终是绝对边界；新的智能体进程默认获得独立的会话授权；标记为每次调用都需批准的凭据仍然要在每次使用时询问。实现可以选择独占所有权或安全交接，但不能为了让两个 bundle 使用起来方便，就悄悄削弱这些控制。

最后，用一句其他工程师可以质疑的清晰结论结束测试：哪个进程拥有保险库，另一个副本出现时发生了什么，是否有操作越过批准或撤销边界，以及一次离线验证是否覆盖了生成的全部事件。如果答案取决于你碰巧观察的是哪个窗口，就重新测试。证据还不够好。
