# macOS 上的代理批准必须区分每个构建版本

通过一个容易记住的命令名称批准 AI 代理，往往等于批准了错误的可执行文件。在一台 Mac 上，很容易同时积累供应商发行版、测试版、包管理器副本和源代码构建，而它们都使用同一个名称。如果你的批准边界无法区分这些副本，那么针对经过审查的构建所做的决定，就可能悄无声息地适用于另一个版本。

解决办法不是把允许列表写得更长。你需要先确定在自己的环境中，哪些属性可以识别一次代理运行；然后证明每个已安装副本都具备预期属性；最后在两个副本同时存在时测试批准行为。路径、签名、指定要求和哈希回答的是不同问题。把它们混为一谈，才是团队真正容易出问题的地方。

## 一个命令名称可能指向多个可执行文件

shell 命令只是查找方式，不是身份。当你输入 `claude`、`agent` 或某个包装器名称时，shell 可能解析出别名、函数、shim、符号链接、包管理器目录，或者 PATH 中排在你预期副本之前的文件。

从真正启动代理的终端、编辑器、启动服务或自动化运行器开始检查。不要只查看方便的交互式 shell，就假设结果适用于其他环境。登录 shell、GUI 应用和 CI 运行器经常会收到不同的环境变量。

先运行：

```sh
type -a agent-name
command -v agent-name
```

结果可能类似这样：

```text
agent-name is /Users/me/bin/agent-name
agent-name is /opt/homebrew/bin/agent-name
agent-name is /usr/local/bin/agent-name
/Users/me/bin/agent-name
```

这说明在当前 shell 中，第一个路径会优先使用。但它没有说明 `/Users/me/bin/agent-name` 是直接二进制文件、符号链接、启动另一个二进制文件的脚本，还是会先修改环境变量、再把控制权交给其他进程的包装器。

检查文件前，先解析它：

```sh
BIN="$(command -v agent-name)"
python3 - <<'PY' "$BIN"
import os, sys
print(os.path.realpath(sys.argv[1]))
PY
file "$BIN"
```

如果 `file` 报告它是 shell 脚本，就读取脚本。如果它是符号链接，就检查目标。如果它是通用 Mach-O 可执行文件，就检查该可执行文件。包装器可能让批准界面看起来正确，但稍后启动一个不同的子进程。

不要找到当前生效的副本就停止。把 `type -a` 返回的所有结果都列入清单，同时检查供应商的 Applications 文件夹、Downloads 文件夹、源代码检出目录和包管理器 cellar 中的副本。今天优先使用的副本，可能在包管理器升级或稍微修改 PATH 后变成另一个版本。

## 路径只能证明位置，不能证明身份

团队往往从路径规则开始，因为路径很容易读取。`/Applications/Agent.app` 看起来像是有意安装的，`/Users/me/dev/agent/bin/agent` 看起来像是实验版本。这些都是有用线索，但路径本身无法证明当前文件中是什么代码。

包管理器可以替换稳定符号链接指向的文件。直接安装程序可以原地覆盖应用包。本地构建也可以每次使用同一个输出路径。拥有足够写入权限的攻击者还可以替换获准路径中的文件。路径只能告诉你加载器在哪里找到了它。

在清单中使用四个独立字段：

| 字段 | 能告诉你的内容 | 无法告诉你的内容 |
| --- | --- | --- |
| 解析后的路径 | 本次启动在哪里找到文件 | 谁生成了它，或它是否发生过变化 |
| 签名信息 | 哪个签名身份附加到了代码上 | 同一签名者构建的另一个版本是否行为完全相同 |
| 指定要求 | macOS 与代码关联的连续性规则 | 这条规则对于你的批准目的是否足够狭窄 |
| SHA-256 哈希 | 你检查的确切字节 | 未来更新是否应该继承信任 |

这种区分很重要，因为每个字段的变化速度不同。供应商更新可能保留路径和指定要求，却改变哈希。测试版可能保留签名机构，但使用不同的软件包标识符。本地构建可能与发行版使用同一个源代码修订版，却带有临时签名或完全没有签名。

Apple 的 Technical Note TN2206 解释了指定要求的预期作用：它应当匹配程序的合法更新，同时排除无关代码。因此，它是一种连续性机制，但不是回答“这是不是我想要的那个确切二进制文件”的通用答案。Apple 还指出，默认要求是根据签名设置合成的，因此它的范围取决于开发者如何签署构建产物。

批准系统也需要同样的区分。信任某个发布者下一次兼容的发行版，与信任某一个确切的发行版构建产物，是两种不同决定。把它们当成同一决定，就无法解释用户究竟批准了什么。

## 创建批准规则前先检查签名

对于每个候选可执行文件，记录签名细节和哈希。下面的命令只使用 macOS 内置工具和 `shasum`：

```sh
inspect_agent() {
  target="$1"
  echo "=== $target ==="
  echo "Resolved path: $(python3 - <<'PY' "$target"
import os, sys
print(os.path.realpath(sys.argv[1]))
PY
)"
  shasum -a 256 "$target"
  codesign --display --verbose=4 "$target" 2>&1 \
    | grep -E '^(Executable|Identifier|TeamIdentifier|Authority|CDHash)='
  codesign --display -r- "$target" 2>&1 \
    | sed -n '/designated/,$p'
  codesign --verify --strict --verbose=2 "$target" 2>&1
}

inspect_agent "$(command -v agent-name)"
```

输出的结构比某个具体供应商字符串更重要：

```text
=== /opt/homebrew/bin/agent-name ===
Resolved path: /opt/homebrew/Cellar/agent-name/2.4.1/bin/agent-name
3b1f...  /opt/homebrew/bin/agent-name
Executable=/opt/homebrew/bin/agent-name
Identifier=com.example.agent
TeamIdentifier=AB12CDE345
Authority=Developer ID Application: Example, Inc. (AB12CDE345)
CDHash=9c1a...
designated => anchor apple generic and identifier "com.example.agent" and certificate leaf[subject.OU] = "AB12CDE345"
/opt/homebrew/bin/agent-name: valid on disk
/opt/homebrew/bin/agent-name: satisfies its Designated Requirement
```

不要把显示出来的 `CDHash` 当成 SHA-256 记录的替代品。CodeDirectory 哈希属于代码签名机制，可能随着签名结构变化。`shasum -a 256` 会为测试记录提供熟悉的精确文件指纹。调查冲突时，应同时记录两者。

对于应用包，应检查实际可执行文件，而不是只检查包目录。可以这样找到它：

```sh
APP="/Applications/Agent.app"
EXEC="$APP/Contents/MacOS/$(defaults read "$APP/Contents/Info" CFBundleExecutable)"
inspect_agent "$EXEC"
```

如果代理会启动辅助程序，也要检查辅助程序。打开终端窗口的可执行文件，不一定是发起 HTTP 请求或 SSH 连接的进程。批准系统应识别请求敏感操作的进程，而测试应验证完整的启动链。

Apple 较新的 TN3127 比旧版签名指南更进一步，展示了不同签名类型下默认指定要求的差异，以及为什么分别分发的变体可能无法相互兼容。这提醒我们不要凭假设行事。请比较已安装文件中的实际要求文本。

## 稳定版、测试版、包管理器版本和本地构建需要测试矩阵

让清单具体起来。选出 Mac 上可能存在的副本，为每个副本设置一个简短标签，并记录你预计它们应当共享或不共享哪些属性。

| 标签 | 常见来源 | 预期签名状态 | 批准预期 |
| --- | --- | --- | --- |
| 稳定版 | 供应商安装程序或应用包 | 发行版签名身份 | 基准候选项 |
| 测试版 | 供应商测试渠道 | 可能与发行版共用签名身份 | 必须单独测试 |
| 包管理器 | Formula、cask、npm 风格 shim 或类似方式 | 取决于上游打包方式 | 必须解析实际启动目标 |
| 本地版 | 源代码检出目录或构建输出 | 开发签名、临时签名或未签名 | 默认单独审查 |

这张表并不规定正确答案，而是防止你采取懒惰的答案，也就是认为渠道名称已经提供了足够信息。测试版可能与稳定版使用完全相同的签名。包管理器副本可能是未经修改的供应商产物、重新打包的产物，或会下载另一个可执行文件的脚本。本地构建可能带有有效的开发签名，看起来比实际应有的可信度更高。

为每个已安装副本建立一行，并将其记录在与代理设置说明放在一起的文本文件中：

```text
label: stable
launch path: /Applications/Agent.app/Contents/MacOS/agent-name
resolved path: /Applications/Agent.app/Contents/MacOS/agent-name
version: 2.4.1
identifier: com.example.agent
team or authority: AB12CDE345
sha256: 3b1f...
designated requirement: anchor apple generic and identifier "com.example.agent" ...
expected approval group: release

label: local
launch path: ~/src/agent/build/agent-name
resolved path: /Users/me/src/agent/build/agent-name
version: git revision recorded separately
identifier: ad hoc or absent
team or authority: none
sha256: 8e52...
designated requirement: unavailable or different
expected approval group: local only
```

记录版本，但不要让版本号决定批准结果。版本字符串属于应用元数据。文件可以声称某个版本号，却并不是你以为下载的那个发行版。签名和哈希提供了独立事实。

比较棘手的情况是，两行记录拥有相同的标识符、团队和指定要求，但哈希不同。这不一定是缺陷，而是说明发布者构建了两个版本，macOS 可能会把它们视为同一程序的实例。如果你希望批准跟随发布者的发行渠道，这可能可以接受。如果你只想让批准覆盖某个特定发行版构建产物，那范围就太宽了。

## 错误的测试方式是逐个批准每个副本

周一测试稳定版，周二测试测试版，几乎无法证明它们不会相互覆盖。每次测试都可能弹出提示，因为当时还不存在有效批准。你需要同时安装两个副本，并让其中一个副本的会话保持活动状态，同时让另一个副本请求访问权限。

在一次性账户中，或使用无法修改生产系统的凭据，按以下顺序操作：

1. 放置稳定版、测试版、包管理器版本和本地版本。确认它们的解析路径并保存检查输出。
2. 通过使用的批准工具清除或撤销现有代理会话。确认下一次敏感调用需要新的决定。
3. 启动稳定版，向测试端点发起一次无害调用。只批准这次运行，并让进程保持活动状态。
4. 稳定版进程仍在运行时，启动测试版并发起同样的无害调用。观察它是否请求批准，并检查卡片上显示的进程身份。
5. 对包管理器版本和本地版本重复测试。然后反转 PATH 顺序，再重复包管理器测试。

你希望看到的结果取决于所选规则。如果稳定版和测试版应该分开，那么稳定版仍获批准时，测试版必须重新请求批准。如果它们有意共享批准组，那么卡片和审计轨迹必须提供足够细节，让审查者清楚看出这种分组。

不要在测试中调用生产 API。使用会返回固定响应、并在自身日志中留下明显且无害标记的测试端点。对于 SSH，使用一个命令被限制为打印身份信息的测试账户：

```sh
ssh agent-test@host.example 'id; hostname; date -u +%FT%TZ'
```

测试需要观察两件事：批准界面显示了什么，以及目标系统记录了什么。保存时间戳、可执行文件哈希、进程 ID 和返回标记。如果包装器改变了实际运行的可执行文件，这种差异会比事后事故讨论更早暴露出来。

一种常见故障是这样的：开发者批准了稳定版应用，随后安装测试版。由于修改了 shell 设置，`/Users/me/bin` 被放到了 `/Applications` 之前。命令名称没有变化。测试版发起调用时没有新的提示，因为授权检查识别到了共享签名身份，或过于宽泛的进程分组。没有人注意到这一点，因为原来的稳定版进程仍在运行，而审计记录只写着 `agent-name`。防止这种故障的方法是强制进行重叠测试，而不是阅读版本发布说明。

## 进程身份和确切字节回答不同的批准问题

批准可以合理地绑定到一个在更新过程中保持稳定的签名进程身份，也可以绑定到确切的文件字节。两种选择都不是普遍正确的。

当你打算信任来自已知签名者的持续维护发行线时，可以使用进程身份。这样，用户不必因为可执行文件哈希在每次补丁版本中发生变化而反复批准，也能让固定进程身份在正常更新路径中保持有效。

当构建产物是实验性的、本地生成的、经过独立修改的，或来自你不希望与稳定版合并的渠道时，应使用确切字节。哈希锁定尤其适合短期调查或复现，因为文件变化后，你可以让批准失效。

危险的中间做法，是假装单独的签名标识符就是身份。Apple 文档说明，同一个签名标识符可能被多个签名者声明。Apple 建议在检查代码时，将它与相关验证信息和团队限制结合起来。实际来说，只有 `Identifier=com.example.agent`，却没有机构或团队信息的标识符，只是一个别人也可能重新使用的标签。

指定要求通常更强，因为它可以把标识符与签名机构限制结合起来。但它仍可能比你的意图更宽。接受某团队在某标识符下签署的任何有效构建，可能会正确覆盖稳定版更新、测试版和供应商生成的本地测试构建。只有在你确实希望这三者属于同一批准组时，这才是好事。

在清单条目旁边用直白的语言写下分组决定。例如：

```text
Release group: accept future vendor-signed builds with the release identifier.
Beta group: separate, even when signed by the same vendor identity.
Local group: exact SHA-256 only; rebuild requires another approval.
```

这段说明会在界面要求点击之前，迫使你先作出决定。它还会暴露工具是否能够表达这种区分。如果工具做不到，那么在工具支持之前，采用更窄的操作边界，例如让测试版和本地版逐次调用批准。

## 会话批准不能替代逐次调用批准

会话批准回答的是“这个代理进程能否在本次运行期间访问资源？”逐次调用批准回答的是“现在是否允许使用这个特定凭据执行这个特定操作？”第一种控制限制哪些进程可以获得会话，第二种控制该会话可能造成的后果。

Sallyport 使用固定的决定层级：锁定的保险库会拒绝所有操作；新代理进程通常需要会话批准；选定的凭据可以要求每次使用都重新批准。批准卡片会优先显示进程的代码签名机构，这是有用的证据，但当存在多个构建版本时，仍应进行重叠测试。

对于可能造成不可逆或外部可见变化的凭据，应使用更频繁的检查。生产部署凭据、破坏性管理 API，以及能够修改共享基础设施的 SSH 访问，不应仅仅因为代理进程在长时间运行开始时可以接受，就继承信任。

不要为了解决二进制冲突，就永久把所有凭据都设为每次调用批准。那会把设计问题变成批准疲劳。人们会在没有阅读的情况下批准重复且可预测的提示。应先分开不应共享访问权限的构建版本，再把逐次调用批准留给必须让人看到使用时刻的操作。

对于操作网关，在操作边界上测试同一矩阵。启动每个二进制文件，如果两个通道都在使用，就各发起一次测试 HTTP 请求和测试 SSH 命令，然后比较会话记录与单次调用记录。记录应该让你回答：哪个可执行文件发起了请求，哪个批准覆盖了它，以及该凭据是否需要再次确认。

## 包管理器和 shim 会隐藏你需要检查的可执行文件

包管理器经常安装指向其他位置的稳定入口路径。`/opt/homebrew/bin/agent-name` 可能是指向版本化 Cellar 目录的符号链接。另一个工具可能安装 JavaScript、Python 或 shell shim，由它选择运行时，然后从缓存目录加载软件包。

沿着这条链一直追踪到真正发起敏感请求的进程。以下命令适用于常见情况：

```sh
ls -l "$(command -v agent-name)"
readlink "$(command -v agent-name)" || true
head -n 40 "$(command -v agent-name)" 2>/dev/null || true
```

在 macOS 上，`readlink` 可能只显示一跳。第一节中的小型 Python 解析器更适合找到最终目标。如果入口文件是脚本，就搜索 `exec`、运行时调用、下载的二进制路径，以及用于选择发行渠道的环境变量。

不要假设包管理器版本等同于具有相同版本号的供应商版本。软件包可能应用补丁、重新打包二进制文件、从源代码编译，或启动不同的运行时。把已安装的可执行文件当作批准对象，包管理器收据只能作为补充背景。

同样的提醒也适用于 IDE 扩展和终端集成。图形启动器可能捆绑一个副本，而 shell 使用另一个副本。对每个能够启动代理的入口点进行测试。“它在 Terminal 中能正确弹出提示”并不能证明编辑器启动的后台任务也会如此。

## 本地构建必须明确表明它是本地版本

本地构建之所以有用，正是因为它可以与发行版不同。它可能包含一个补丁、未经审查的依赖更新、编译器变化、调试标志，或从未进入供应商产物的生成文件。它不应悄悄借用发行版构建的批准信誉。

先检查它的签名状态：

```sh
LOCAL="$HOME/src/agent/build/agent-name"
codesign --display --verbose=4 "$LOCAL" 2>&1 | sed -n '1,25p'
codesign --verify --strict --verbose=2 "$LOCAL" 2>&1
shasum -a 256 "$LOCAL"
```

未签名输出、临时签名和开发签名并不等价。未签名的可执行文件只能提供较弱且不持久的身份证据。临时签名可能让文件看起来已签名，却没有将它与开发者身份关联起来。开发签名可以识别开发环境，但仍不能让文件成为发行版产物。

最安全的默认做法很简单：把本地二进制文件放在单独目录中；如果你能控制构建过程，就给它们设置明显不同的命令名称；每次哈希变化都要求新的批准。如果不能重命名命令，就在运行手册和测试输出中明确显示解析路径及本地构建状态。

不要采用“为了减少提示，就用发行版证书给每个本地构建重新签名”的流行建议。它之所以流行，是因为能让开发更顺畅；但当你的批准模型把签名机构作为重要边界时，它就是错误做法。你已经把发行版身份扩大到所有能够访问该证书的机器和脚本。除非发行流程能够维护这一主张，否则不要把发行签名材料用于随意的本地构建。

## 审计记录必须让你日后还原决定

只写着“代理已批准”的批准记录，证据价值很弱。六周后，你无法判断获批程序来自稳定版安装程序、测试版文件夹、包管理器符号链接，还是本地检出目录。

对于每次测试和重要的运行变化，保留以下事实：

- 启动路径和最终解析路径
- 签名标识符、机构或团队，以及指定要求文本
- SHA-256 哈希和应用版本
- 进程 ID、启动时间和会话结果
- 操作目标和单次调用结果

Sallyport 会在 Sessions 日志中保存代理运行，在 Activity 日志中保存单次操作。两者都由加密哈希链审计日志生成。离线的 `sp audit verify` 检查可以确认加密日志链是否仍然通过验证，但日志完整性无法弥补你从未记录的身份字段。趁还能检查机器时，把可执行文件证据作为事件上下文的一部分保存下来。

修改构建矩阵、撤销会话并重复重叠测试后，运行审计验证。你需要确认两件事：系统记录了预期的独立运行，而且单独读取时记录链仍然通过验证。完整的链条只能证明记录连续，并不能证明最初的分组决定明智。

## 把它变成回归测试，而不是一次性清理

多个副本总会回来。有人安装测试版来验证修复，包管理器夜间升级，同事分享本地构建，稳定版应用原地更新。如果只在出问题后测试，就会等到代理已经拥有访问权限时才发现冲突。

保留一个简短测试脚本，为每个候选路径写入带时间戳的报告。在安装发生变化后、授予新的高影响凭据前，以及修改 shell 启动文件或编辑器代理设置时运行它。

```sh
#!/bin/zsh
set -eu

for candidate in \
  "/Applications/Agent.app/Contents/MacOS/agent-name" \
  "/opt/homebrew/bin/agent-name" \
  "$HOME/src/agent/build/agent-name"; do
  [[ -e "$candidate" ]] || continue
  echo "### $candidate"
  echo "resolved: $(python3 -c 'import os,sys; print(os.path.realpath(sys.argv[1]))' "$candidate")"
  shasum -a 256 "$candidate"
  codesign --display --verbose=4 "$candidate" 2>&1 \
    | grep -E '^(Identifier|TeamIdentifier|Authority|CDHash)=' || true
  codesign --display -r- "$candidate" 2>&1 \
    | grep 'designated' || true
  echo
 done
```

将报告与上次审查的副本进行比较。更新后哈希变化是正常的。签名机构、标识符或指定要求变化，则应在将其视为常规更新前作出明确决定。`type -a` 中出现在发行版路径之前的新路径，也需要同样重视。

实际标准很简单：批准应适用于你有意选择的可执行文件组，而证据应说明为什么该组包含一个构建版本、排除另一个版本。把稳定版、测试版、包管理器版本和本地版本放在同一台 Mac 上，让一个已批准的进程持续运行，并强制其他每个副本重新请求批准。如果结果出乎意料，说明批准边界过于模糊。
