# 代理批准提示如何防止跨运行误批准

自主代理的批准队列不是一摞权限按钮，而是一套实时的归属系统。当两个或更多代理运行可以请求同一个凭据、调用同一个 API，或打开同一个 SSH 连接时，审核者在批准前必须能回答一个明确的问题：*究竟是哪个正在运行的进程请求我为这个具体操作授权？*

大多数批准界面之所以失败，是因为它们让每张卡片看起来都来自一个叫作“代理”的通用角色。在只有一个终端窗口的演示中，这个标签没有问题。但当一个编程代理正在修复生产事故，另一个代理正在准备发布，而两者使用同一个客户端并请求访问同一服务时，情况就变得危险。审核者看到一个熟悉的目标，大致认出了任务，于是批准了正确的操作，却批准给了错误的运行。

## 批准是一项归属判断

审核者并不是孤立地批准一个 HTTP 请求。他们之所以批准，是因为这个请求属于某项特定工作，而他们预期这项工作正在进行。如果界面无法保留这种关系，批准按钮就会变成披着人工控制外衣的猜测。

把每次批准都视为包含五个部分的一句话：

1. 某个具体的可执行文件或代理进程发起了请求。
2. 该进程的某个具体会话仍在运行。
3. 该会话声明自己负责一项有名称的任务或工作项。
4. 它想执行一个具体的外部操作。
5. 审核者要么授权这个完整组合，要么拒绝它。

第五点的重要性超出直觉。“批准访问部署 API”并不是完整的表述。审核者可能愿意让发布运行读取构建记录，却不愿意让无关的调试运行修改发布设置。单凭目标无法传达完整含义。

之所以容易混淆，是因为同一个代理客户端经常会创建多个运行。命令行可能完全相同，代码签名来源可能相同，代码库路径也可能相同。代理甚至可能生成几乎一样的请求。但这些都不能让两个同时发生的调用成为同一个安全主体。

我见过一些团队在真正缺少归属信息时，反而不断给操作摘要增加文字。他们把“POST /deployments”改成“POST /deployments，以触发版本 1.8.4 的暂存环境部署”。这确实有助于解释操作，但审核者看到的仍然是两张都声称要执行暂存部署的卡片。更好的句子无法弥补缺失的会话边界。

批准界面必须让身份层级清晰可见。操作是主要对象。发起进程和活动会话解释了为什么会出现这个操作。任务标签帮助审核者识别意图，但不能替代任何一个技术标识符。

## 经过签名的进程不等于正在运行的会话

代码签名身份可以告诉系统是谁签署了代码，却不能告诉审核者是哪一次调用生成了待处理请求。Apple 的代码签名文档也用不同的语言划出了类似边界：代码要求用于确立代码身份，而指定要求用于描述跨版本哪些代码应被视为同一代码。这些信息可以作为调用方的证据，却不能替代运行 ID。

当批准提示开头类似下面这样时，这一点尤其重要：

```text
Request from: Acme Agent CLI
Signed by: Example Engineering, Team ABCD1234
```

这是一个不错的开始。它能让审核者判断是否是预期的客户端请求访问。但它无法回答：这是五分钟前启动的发布助手、今天早上一直开着的测试助手，还是第二个终端中运行的一条复制出来的 shell 命令？

进程身份和会话身份回答的是不同问题：

| 字段 | 它回答的问题 | 它无法回答的问题 |
|---|---|---|
| 代码签名来源 | 谁生成了这个可执行文件？ | 这是哪一次调用？ |
| 可执行文件路径 | 哪个已安装的客户端启动了它？ | 它正在执行什么任务？ |
| 进程 ID | 当前哪个本地进程拥有这个连接？ | 重启后人还能识别它吗？ |
| 会话 ID | 哪一次有边界的运行发出了请求？ | 请求的操作是否合理？ |
| 任务标签 | 用户要求这次运行做什么？ | 标签是否真实，或是否足以作为证据？ |

不要因为增加了第四行，就隐藏前两行。审核者需要进程来源，以发现意外的客户端；需要会话 ID，以区分同时运行的预期客户端；还需要任务标签，把技术标识符与对工作内容的理解联系起来。

实际规则很简单：将进程身份显示为来源信息，将会话身份显示为获得授权的对象。

在 macOS 上，单独使用代码签名标识符作为批准标签尤其薄弱。Apple 明确指出，多个签名者可以声明相同的签名标识符，并建议将标识符检查与验证类别结合起来；对于非 Apple 代码，还应加入团队标识符。只显示友好的应用包名称，或只显示声明的标识符，都会让审核者获得的证据少于平台本身能够提供的证据。

不要把原始要求语言作为主要标签。它很精确，但大多数审核者无法快速读懂并据此做决定。用通俗语言显示签名来源，把技术要求保留在详情中，并与易于识别的可执行文件名称放在一起。然后将会话标识符放在无需展开就能看到的位置。

## 让每次运行都有能穿过队列的会话身份

会话 ID 必须在第一次受保护调用之前生成，在整个运行期间保持稳定，并在运行结束时从授权集合中消失。任何更弱的设计都会在负载升高时制造歧义。

在连接或进程注册时生成高熵的不透明 ID。审计记录保存完整值，但在提示中显示简短且不含糊的前缀。显示形式应足够长，让两个活动会话极不可能共享同一个前缀；如果真的发生碰撞，界面也必须让人立刻注意到。不要根据当前时间、队列位置、代码库名称或单独的进程 ID 推导会话 ID。

一个有用的内部记录可以是：

```json
{
  "session_id": "ses_7TQ4N8M2KDXP6R9V",
  "display_id": "7TQ4N8M2",
  "process": {
    "pid": 84172,
    "executable": "/usr/local/bin/agent-cli",
    "signing_authority": "Example Engineering (Team ABCD1234)"
  },
  "task": {
    "label": "Prepare the staging release notes",
    "workspace": "/Users/maya/work/app"
  },
  "started_at": "2026-07-22T16:42:11Z"
}
```

任务标签应来自用户可见的指令或有意设置的会话标题，而不是来自每次工具调用都会变化的模型生成句子。模型可以提出标签，但系统应在会话开始时将其固定下来。如果会话从“准备发布说明”转变为“轮换生产环境 webhook 凭据”，审核者应看到明确的任务变更，或看到一个新的会话。悄悄改写标签会让历史记录更难阅读，也会让运行借用早期、更安全任务的可信度。

采用边界明确的生命周期：

- 在任何需要凭据的操作之前创建会话。
- 将每个请求、批准、拒绝、取消和结果都关联到该会话。
- 当发起进程退出或失去有效连接时关闭会话。
- 当审核者选择撤销时立即撤销会话。
- 会话关闭或撤销后，即使卡片暂时仍然可见，也要拒绝待处理批准。

最后一点可以避免一个隐蔽但常见的竞态。审核者看到的是运行 A 的卡片。运行 A 退出，新的运行 B 开始，并请求类似操作，而旧卡片仍然接受输入。如果批准服务把这次点击关联到“最近一次请求这个凭据的请求”，审核者看着 A，却批准了 B。每张卡片都必须绑定一个不可变的请求 ID 和一个会话 ID。当其中任意一个失效时，唯一安全的按钮就是关闭。

避免使用“会话 1”“代理运行”或“当前任务”这类标签。它们在第二次运行开始前看起来没问题。标签可以友好，但界面还需要一个在进程重启、笔记本休眠或数天后进行事故复盘时仍有意义的标识符。

## 在前两行放入正确的信息

批准提示的前两行应让审核者无需打开详情，就能把这条请求与其他所有待处理请求区分开。先放请求执行的操作，再在下面直接写出发起进程和会话。

一张可用的卡片可以是这样：

```text
Allow POST to api.example.internal/v1/releases?
agent-cli, signed by Example Engineering, session 7TQ4N8M2
Task: Prepare the staging release notes

Creates a release record named "2026.07.22-rc3"
Credential: release-service-write
[Review request]                         [Deny] [Allow]
```

操作行说明会发生什么以及目标在哪里。归属行说明是谁提出了请求。任务行说明审核者应将请求与哪项工作联系起来。预览则提供足够的信息，帮助审核者做出初步判断。这样的顺序是有意安排的。

不要以“请求批准”或“代理想使用一个密钥”开头。这些说法无法帮助审核者整理繁忙的队列。也不要以凭据名称开头。凭据属于实现细节。审核者通常先判断请求是否属于正确的发布运行，然后才会确认 `release-service-write` 是否是合适的存储密钥。

对于 SSH，类似的提示应写出远程主机和命令类别，而不是只说“SSH 访问”。例如：

```text
Allow SSH command on build-staging-03.example.internal?
agent-cli, signed by Example Engineering, session 7TQ4N8M2
Task: Verify the staging migration

Runs: /usr/local/bin/check-migration --database app_staging
Credential: deploy-ssh
[Review command]                         [Deny] [Allow]
```

如果系统无法生成安全且易读的预览，就应明确说明。“无法获取命令参数”好过编造一句隐藏 shell 展开、间接脚本或未经检查输入的友好描述。审核者随后可以打开完整命令，或拒绝请求。

好的提示不会要求审核者根据时间戳推断发起请求的运行。时间应放在详情和活动记录中。两个代理可能在同一秒发出请求，而人们并不能可靠地依靠时间戳把卡片与某个终端对应起来。时间只能作为辅助证据，不能成为承载归属信息的标签。

也不要把会话 ID 放在浅色页脚中。这个标识符不是诊断性杂项。在并行队列中，它正是防止审核者把两张几乎相同的卡片当成可互换对象的字段。

## 队列需要分组，但不能错误合并

并行工作会带来视觉整理问题。按会话分组可以帮助审核者，但当界面把请求之间有意义的差异折叠掉时，分组也会变得危险。

队列应让审核者看到某个会话的所有待处理操作，同时在操作影响不同的情况下保留每个操作独立的批准决定。一个发布助手如果需要进行三次只读 API 调用，可以合理地显示成一个紧凑分组。一个会话如果要读取问题、写入部署记录并执行远程迁移，就应显示三个独立决定，因为它们的后果不同。

错误的模式是显示一个全局横幅：

```text
agent-cli requests access to 5 services
[Allow all]
```

这个按钮要求审核者在确认每个项目属于哪个运行、检查每个项目的影响之前，就批准一组请求。它还会助长队列疲劳。审核者越频繁看到这个横幅，就越可能养成清除它、尽快回到工作的习惯。

更好的队列会使用会话标题提供上下文，而不是授予宽泛权限：

```text
Session 7TQ4N8M2 · agent-cli · Prepare the staging release notes
2 pending requests

  GET api.example.internal/v1/builds/rc3             [Allow]
  POST api.example.internal/v1/releases              [Review]

Session C5J1W6PA · agent-cli · Investigate test failure #1842
1 pending request

  SSH build-staging-03.example.internal              [Review]
```

审核者可以按运行快速浏览，但每一行仍然提出一个独立请求。如果系统支持按会话授权的状态，就在标题中显示清晰的范围，例如：“此会话在退出前获得授权。”不要让这种状态看起来像批准了每个调用操作。按会话授权只允许运行请求操作，并不会把意外操作变成预期操作。

排序也会影响错误率。纯按时间排序的队列可能把五个运行的请求彻底交错，让审核者无法保持任何一个运行的上下文。纯按会话分组的队列又可能把紧急请求埋在嘈杂的会话后面。应提供两种视图：默认使用按会话分组的视图，便于判断归属；另提供按时间排序的视图，用于事故响应。无论在哪种视图中，都要保留会话标记。

不要仅仅因为两个请求共享目标和凭据，就把它们合并。两个不同运行同时写入同一个端点，正是队列必须解决的歧义。相似性应成为突出运行身份的理由，而不是合并批准的理由。

## 批准必须绑定到审核者检查过的确切请求

即使提示正确识别了运行，如果批准适用于可变模板，而不是不可变操作，也仍可能授权错误的请求。审核者必须批准一个具体的请求表示，而不是批准未来某个碰巧复用了相同路由、命令或凭据的请求。

在卡片出现前创建规范化的操作记录。对于 HTTP，至少包含方法、规范化的来源和路径、按名称选择的请求头、凭据别名以及请求正文的摘要。对于 SSH，包含规范化的主机身份、请求账户、端口、命令字节和凭据别名。完整的受保护材料应保存在可信组件中，不能因为审核者需要看到提示，就把它发送给代理。

然后将批准绑定到规范化记录：

```json
{
  "approval_id": "apr_K9H2D7LQ",
  "session_id": "ses_7TQ4N8M2KDXP6R9V",
  "action_digest": "sha256:2a13c4e0...",
  "expires_at": "2026-07-22T16:47:11Z",
  "decision": "allow_once"
}
```

执行时，根据实际要离开本机的操作重新计算摘要。如果摘要不同，就拒绝批准并创建新卡片。不要悄悄把变更后的请求视为“差不多”。变更的 URL 参数可能重定向付款，变更的请求头可能改变账户范围，变更的 shell 参数可能把检查命令变成写入命令。

许多看似周全的系统就在这里失败。它们显示可供审核的摘要，批准“使用凭据 X 访问端点 Y”，随后却允许代理在这个宽泛授权下发出第二个请求。审核者并没有审核第二个请求。系统把一次明确的决定变成了不可见的策略规则。

待处理批准应设置较短的有效期。目的不是惩罚去喝咖啡、暂时离开的审核者，而是防止周围上下文发生变化后，旧决定仍被使用。如果代理稍后仍需要这个操作，可以用相同的进程和会话信息重新请求。审核者也能再次判断它是否仍然属于这次运行。

NIST 关于自动化决策中人工参与的材料警告了自动化偏见，以及低摩擦批准循环如何让警报疲劳变得正常。这里的实际结论很直接：批准更容易点击，并不意味着批准更有意义。界面必须让决定足够小，便于检查；也必须足够具体，能够追责。

## 详情视图应该解决争议，而不是制造争议

紧凑的批准卡片无法容纳请求的每一个字节，但必须提供详情视图，回答谨慎的审核者在面对陌生、昂贵或可疑操作时会提出的问题。

对于 HTTP 操作，应显示完整的方法和 URL，默认打码的请求头，对正文中各字段以及敏感信息的明确处理，凭据别名，发起进程身份，会话 ID，任务标签和请求创建时间。如果正文是二进制数据或过大而无法渲染，应显示其大小、内容类型和摘要。不要假装一行文字摘要足以替代这些信息。

对于 SSH，应显示主机、用户、端口、可用时的主机验证状态、将要执行的完整命令、相关的工作目录，以及所有会影响行为的环境值。命令预览必须保留引号和参数边界。将数组松散地拼接成 shell 字符串，可能让安全命令看起来不安全，更糟的是，也可能让不安全命令看起来无害。

为了安全而打码，与为了省事而省略信息，有明显区别。应从提示中打码 bearer token、私钥、密码或敏感正文值。但不要因为详情会让卡片变长，就省略路径、目标账户、远程主机、命令或变更字段。这些往往正是判断请求是否属于当前运行的关键信息。

在详情视图中使用稳定的请求指纹。审核者联系同事时，应能说：“我拒绝了会话 `7TQ4N8M2` 的请求 `apr_K9H2D7LQ`。”同事也应能找到唯一对应的记录。不要让人只能把事件描述为“4:40 左右的第二个部署提示”。这种说法在需要精确调查事故时就不够用了。

审核界面还应说明批准会授权什么，以及不会授权什么。例如：

```text
Allow once
This decision authorizes only this POST request with digest 2a13c4e0…
It does not authorize later calls from session 7TQ4N8M2.
```

这句话值得占用界面空间。它能防止审核者误以为自己授予了宽泛权限，也能防止实现者以后在同一个按钮标签背后扩大授权范围。

## 取消和撤销必须产生清晰可见的结果

审核者最终会批准错误的卡片，或发现某个运行已经偏离预期。恢复过程必须快速、清晰可见，并与批准过程中使用的同一组标识符绑定。

界面需要分别提供拒绝单个请求和撤销单个会话的操作。拒绝表示“不要执行这个操作”。撤销表示“这个运行中的进程失去授权，取消它的待处理批准，并拒绝之后的操作”。两者的后果不同，因此不能共用一个含义模糊的“停止”按钮。

审核者撤销会话时，应显示包含进程、会话 ID 和任务名称的确认信息：

```text
Revoke session 7TQ4N8M2?
agent-cli is running “Prepare the staging release notes.”
Pending requests from this session will be cancelled. New protected actions
from this process will be denied until it starts a new session.
[Keep session] [Revoke session]
```

如果请求已经在执行，要如实报告。撤销可以阻止未来的特权操作，但可能无法撤回远程服务已经接受的 HTTP 请求，也无法停止已经启动的远程命令。活动记录应显示操作处于待处理、已发送、已完成、失败还是已取消状态。不要用“已撤销”来暗示外部影响已经消失。

这也是每张卡片都显示会话 ID 的另一个原因。在紧张的事故处理中，审核者需要从可疑提示直接找到能停止该运行的控制项。如果队列只显示“agent-cli”，撤销操作就可能误杀有用的运行，却让有问题的运行继续活动。

会话日志和活动日志应使用一致的标识符。一条记录说明进程会话 `7TQ4N8M2` 何时开始、获得授权并被撤销；另一条记录说明它在每次状态变化前后尝试执行了哪些具体操作。如果两类记录使用互不相关的标签，响应人员就不得不重新构建本应由产品明确提供的关联关系。

## 在用户犯错前测试错误运行批准

无需红队或生产事故，就能测试这种失败场景。使用同一个代理客户端启动两个实例，让它们针对同一个工作区发出相似的受保护调用。只有当审核者在繁忙队列中仍能正确识别每个请求时，测试才算成功。

可以进行以下练习：

1. 启动运行 A，任务标签设为“检查失败的暂存构建”。
2. 启动运行 B，任务标签设为“发布暂存版本记录”。
3. 让两个运行在几秒内请求同一个凭据。
4. 让它们的第一次请求相似，例如都调用同一个 API 来源。
5. 修改其中一个操作，使得把它批准给另一个运行会在测试环境中产生明显且不良的影响。

然后让一个没有参与界面开发的人只批准运行 B 的请求。不要通过队列位置告诉他哪张卡片是 B。观察他依据什么做决定。如果他依赖时间戳、卡片顺序，或记得自己先打开了哪个终端，说明界面已经失败。如果他无需打开其他页面，就能指出进程来源、会话 ID、任务、目标和操作，那么这至少是一个可用的起点。

也要测试过期批准的行为。为运行 A 创建卡片，结束运行 A，启动运行 B，然后点击旧卡片。系统必须拒绝点击，并解释该请求已经不再活动。在审核后修改请求正文或 SSH 参数，再重复测试。系统必须要求新的批准，不能把旧批准应用到变更后的操作上。

测试会发生冲突的名称。两个任务都可能叫“修复 CI”。两个代码库可能有相同的目录名。两个分支可能共享同一个发布编号。友好的文字有助于理解，但完整设计仍必须在这些文字含糊时正常工作。

Sallyport 的按会话授权为建立这条边界提供了自然位置：新的代理进程第一次调用时会识别该运行，以便进行批准；而按调用设置的密钥则可以要求每次使用都单独批准。当这些控制项出现时，应继续显示会话标签和进程来源，否则并行运行仍会重新合并成一个模糊的“代理”。

## 审计记录必须能够重现批准决定

只有当审计记录能在事后回答审核者看到了什么、系统执行了什么时，它才真正有用。记录“用户批准了 API 访问”远远不够。它没有解决核心争议：这是哪个运行的访问，针对哪个操作，以及当时显示了哪些上下文？

对于每项决定，记录不可变的标识符和展示信息：

```json
{
  "event": "approval_granted",
  "approval_id": "apr_K9H2D7LQ",
  "session_id": "ses_7TQ4N8M2KDXP6R9V",
  "process_identity": "agent-cli / Example Engineering (Team ABCD1234)",
  "task_label": "Prepare the staging release notes",
  "action_digest": "sha256:2a13c4e0...",
  "displayed_action": "POST api.example.internal/v1/releases",
  "decision_scope": "once",
  "recorded_at": "2026-07-22T16:44:03Z"
}
```

规范化的操作记录应单独保存，或与批准事件一起保存，并设置适当的加密和访问控制。审计事件应告诉调查人员卡片上写了什么；规范化记录则应证明可信组件使用了哪些字节和目标。如果两者不一致，应将其视为安全缺陷，而不是普通的日志问题。

哈希链日志有助于发现历史记录被改写，但它不能修复含糊的事件内容。你可以用密码学方式证明“批准已授予”没有被修改，却仍然无法判断批准授予了哪个请求。完整性和归属解决的是不同问题，两者都需要。

Sallyport 会将会话级记录和单次调用记录写入同一个加密、哈希链式的审计日志；其 `sp audit verify` 命令可以在离线状态下对密文验证链条。这很有用，因为审核者可以在不向验证步骤暴露存储密钥的情况下，保留日志未被改写的证据。

最后的设计测试很简单。随便选一个已经完成的操作，倒推整个过程：调查人员能否识别进程、具体会话、当时显示的任务标签、审核过的确切请求、审核者的决定范围和最终结果？然后从一个会话向前查看：能否按顺序看到所有待处理、被拒绝、已批准、已取消和已执行的操作？如果任一方向都需要猜测，批准队列仍然会诱发把批准给错误运行的事故。

并行代理会让批准队列成为日常工具。解决办法不是用更多卡片淹没审核者，也不是给他们一个宽泛的“全部允许”控制项。应为每个正在运行的进程提供持久的会话身份，把它放在用户真正会查看的操作旁边，并将每次点击绑定到审核者检查过的请求。多个代理同时活动时，批准才会因此真正有意义。
