# 到底是哪一个真实代理进程在请求审批？

写着“Terminal”或“zsh”的审批提示通常太过模糊，无法支持真正的决策。这些程序可能属于启动路径，但往往只是共享的基础设施。审查者需要知道是哪一次代理运行请求执行操作、是什么启动了它，以及这条链中是否存在能提供持久身份的因素。

难点在于，进程树同时回答了几个不同的问题。它可以告诉你谁创建了谁，可以暴露出没人预料到的任务运行器，也可以显示图形化 IDE 启动了终端。但它本身无法证明，一个已签名的父进程批准了子进程将要执行的每一行代码。好的审批设计会把进程树当作证据，然后明确说明它能够支持的有限结论。

## 审批针对的是操作方，而不是终端

审查者批准的操作方，是发起请求的那次进程运行，并且要结合它的启动链来理解。终端窗口只是进程运行的地方，并不会自动成为负责方。

当一个团队在同一个 shell 中运行多个代理时，这个区别就很重要。一名开发者在 iTerm2 中打开终端，在一个标签页运行交互式编程代理，在另一个标签页启动仓库任务，同时在第三个标签页留下后台监视器。三个后代进程的上方都可能出现同一个终端应用。把每个请求都叫作“iTerm2”，几乎无法给审查者提供有用信息。

反方向的错误也很常见。审查者看到 `node`、`python` 或 `zsh`，就以为离请求最近的二进制文件代表完整身份。这个二进制文件可能是代理包的解释器、包管理器钩子、IDE 扩展临时生成的脚本，或团队工具添加的包装器。名称在技术上没错，但在实际操作中毫无帮助。

即使审批卡只显示两个标签，内部也应使用以下三个标签：

- **请求方**：实际请求使用受保护通道的进程。
- **执行链**：说明请求方如何到达这里的相关父进程。
- **可识别权限**：审查者能够合理识别、并且可以评估其代码签名的最近祖先进程。

这些标签解决了许多审批系统混淆的问题。请求方回答“哪个进程发出了这个调用？”可识别权限回答“这次运行是通过谁或哪个组织来的？”二者有关联，但不能互换。

例如，一个请求可能看起来是这样：

```text
Code Helper (signed application)
  └─ zsh -l
      └─ npm run agent
          └─ node ./tools/start-agent.mjs
              └─ agent-cli --workspace /Users/maya/src/payments
```

如果 `agent-cli` 发起 HTTP 请求，它就是请求方。shell 和 npm 说明了启动路径。已签名的 Code Helper 进程可能是最合适的可识别权限，前提是它确实位于当前的实时祖先链中，而不是仅仅在桌面上打开。写着“agent-cli，由 Code Helper 启动”的审批卡是诚实的。只写“Code Helper 请求访问”则丢掉了区分这次运行与另一个扩展或任务所需的信息。

## 进程树是证据，不是意图声明

父进程可以证明它创建了子进程，或与子进程建立了继承关系。但它不能证明父进程理解子进程后续的参数、提示词、仓库指令或远程响应。

当人们不严谨地使用签名术语时，这个限制尤其重要。Apple 将 designated requirement 描述为 macOS 用来判断不同版本代码是否为同一份代码的机制。它通常包含标识符和签名权限。Apple 也明确了边界：未签名代码没有持久的 designated requirement，而临时本地签名无法在不同版本之间提供稳定身份。

这些信息对审批很有用，但并不等于对操作的认可。已签名的 IDE 可以启动未签名的仓库脚本。已签名的终端可以运行下载的二进制文件。已签名的任务运行器可以将恶意环境变量传给一个看似普通的解释器。签名让审查者能够识别代码发布者，但不会自动清理它下面的链条。

应区分以下几种声明：

| 声明 | 支持它的证据 | 它不能证明什么 |
|---|---|---|
| “这个请求来自 PID 81234。” | 本地进程记录 | 谁编写了代码，或它为什么发起调用 |
| “这个子进程由这个父进程启动。” | 父 PID 和实时祖先链 | 父进程批准了子进程当前的行为 |
| “这个可执行文件由该权限签名。” | 签名检查和 designated requirement | 可执行文件是无害的，或正在按预期运行 |
| “这次运行从这个 IDE 或终端开始。” | 未中断的实时祖先链 | 可见窗口启动了所有后代进程 |

实际后果很简单。不要把这四种声明压缩成一个友好的应用图标和一句话。审查者应该能看到自己接受的究竟是哪一种声明。

链条存在歧义时，要明确说出来。“由终端会话中的未签名脚本启动”比把请求归因于终端应用更好的审批标签，因为后者暗示终端编写了请求。虚假的精确性会让人逐渐忽略审批卡。

## 设计审批卡前先检查实时链条

找出正确操作方最快的方法，是在一次真实运行仍然存活时进行检查。让代理执行一个无害操作，因为工作从启动阶段进入任务运行器或子 helper 后，进程链可能会发生变化。

在 macOS 上，如果网关记录了请求方 PID，就从它开始。下面的命令会输出进程、父 PID、启动时间、可执行文件名称和参数：

```sh
ps -p "$PID" -o pid=,ppid=,lstart=,user=,comm=,args=
```

典型结果大致如下：

```text
81234 80991 Tue Jul 21 14:08:31 2026 maya /usr/local/bin/agent-cli agent-cli --workspace /Users/maya/src/payments
```

然后向上检查。`ps` 不会自动递归，因此可以用一个简单的 shell 函数让检查过程保持一致：

```sh
ancestry() {
  local pid="$1"
  while [ "$pid" -gt 1 ] 2>/dev/null; do
    ps -p "$pid" -o pid=,ppid=,user=,comm=,args=
    pid=$(ps -p "$pid" -o ppid= | tr -d ' ')
  done
}

ancestry "$PID"
```

输出有意保持朴素。你要寻找的是进程类型的变化：代理可执行文件变成解释器，解释器变成包管理器，包管理器变成 shell，shell 变成终端或 IDE helper。不要因为认出了一个名称就停止。继续检查，直到找到能为审查者提供有意义来源的进程，或直到本地链条结束。

当存在多个后代进程时，下面的视图有助于获得整体快照：

```sh
ps -ax -o pid=,ppid=,user=,lstart=,comm=,args= | sort -n
```

它适合调查，不适合直接放进审批提示。完整进程列表包含太多无关细节，结构却不够清晰。实现时可以在需要时收集它，然后压缩为请求方、直接父进程、可识别权限，以及能够解释意外启动的中间跳转。

这里有两个时间陷阱。第一，进程退出后 PID 可能被重新使用。应连同 PID 一起记录启动时间，并在请求发生时解析链条，不要依赖早先的样本。第二，任务运行器可能创建 worker 后先退出，而受保护调用稍后才发生。如果父进程已经消失，就保留进程创建时观察到的链条，或报告缺失的链接。不要用猜测出来的父进程替代它。

## VS Code 会改变启动路径，但不会成为请求方

VS Code 的集成终端在启动受支持的 shell 时，可以注入参数或环境变量来启用 shell 集成。其文档也说明，自动注入并不涵盖所有环境，包括某些子 shell、SSH 和复杂 shell 场景。这提醒我们，终端界面元数据和 Unix 父子关系是两种不同的证据来源。

在简单情况下，链条如下：

```text
Visual Studio Code
  └─ terminal helper
      └─ login shell
          └─ agent-cli
```

终端 helper 的存在，是因为 IDE 需要管理伪终端及其子 shell。登录 shell 可能会加载配置文件，修改 `PATH`、激活语言运行时、安装 shell 钩子，或启动项目专用脚本。这些细节解释了为什么同一条命令在独立终端和 IDE 终端中的行为不同。

审批决策不应假装这些细节不存在。将代理可执行文件显示为请求方。只有在 VS Code 位于当前祖先链中时，才把它显示为启动权限。当 shell 和任务命令能够实质性地区分这次运行时，将它们放入可展开的路径中。

常见的失败情况如下：

```text
Visual Studio Code
  └─ zsh
      └─ make test-agent
          └─ sh -c ./scripts/bootstrap-agent
              └─ python3 tools/agent.py
```

简单实现会看到 `zsh`，然后将请求标记为“VS Code 终端”。另一种实现看到 `python3`，就将它标记为“Python”。两者都无法帮助审查者判断这是已知的仓库任务，还是从同一个 shell 启动的任意进程。

更有用的说法接近这样：

```text
Requester: python3 tools/agent.py
Run path: make test-agent > scripts/bootstrap-agent
Launched from: Visual Studio Code integrated terminal
```

这个标签有一个代价：它暴露了请求来自仓库控制的代码。它就应该这样做。如果审查者信任已签名的编辑器，却不信任当前检出的代码，审批卡必须为这种判断留下空间。

不要仅根据终端转义序列、环境变量或窗口标题推断 VS Code 的所有权。它们可能被继承、复制或过期。实时父子关系是更强的证据。IDE 提供的进程上下文可以在确认祖先关系后丰富显示，但不能取代它。

## 独立终端提供上下文，不提供全面权限

iTerm2、Terminal 及类似应用为人提供了一个有意选择的入口。如果开发者打开干净的终端，输入代理命令，并立即收到审批提示，标明终端应用是有用的上下文，因为它说明运行从哪里开始。

但这并不能为恰好共享该会话的所有子进程提供全面审批。shell 的设计就是创建子进程，而长期存在的标签页往往会超过最初打开它的原因。终端标签页中 9:00 启动的命令，可能在午餐时间仍留下 worker，此期间还发生了 `cd`、分支切换、环境变化和三条无关命令。

应让区别清晰可见：

```text
Requester: agent-cli --workspace /Users/maya/src/payments
Parent: zsh -l
Interactive origin: signed terminal application
Session started: Tue Jul 21 14:08:31 2026
```

交互来源只是辅助上下文。会话属于实际的进程运行。如果进程退出，就撤销会话审批，即使终端标签页仍然打开。同一标签页中启动的新代理进程是一次新的运行，拥有新的参数，`PATH` 中也可能是不同的可执行文件。

这正是可识别审查身份变得棘手的地方。终端应用可能由知名发布者签名，而 `agent-cli` 可能是安装在项目目录中的未签名可执行文件。诚实的审批卡应同时说明这两个事实。不要把终端的签名转移给未签名的子进程，好像终端为它提供了背书。

shell 函数和别名也有同样的问题。看起来像 `agent` 的命令可能会展开为一个函数，先切换目录、加载环境文件、启动包脚本，最后再运行另一个二进制文件。进程收集器只能看到最终产生的子进程。如果希望把用户输入的命令作为可选上下文，可以从 shell 集成或 shell 钩子中获取，并标记为用户输入的命令历史，而不是进程身份。

## 脚本和任务运行器会制造最容易误解的链条

任务运行器很受欢迎，因为它们能把复杂的本地设置变成容易记住的命令。但这种便利也常常让它们的进程链看起来比实际更可信。

考虑下面这个普通的启动过程：

```text
zsh -l
  └─ just agent
      └─ /bin/sh -cu 'npm run agent -- --mode write'
          └─ npm run agent -- --mode write
              └─ sh -c node ./scripts/launch-agent.js --mode write
                  └─ node ./scripts/launch-agent.js --mode write
                      └─ agent-cli --mode write
```

每个中间进程都有合理用途。`just` 选择配方，`/bin/sh` 解释配方内容，npm 选择包脚本并启动 shell，Node 执行启动脚本。然而，这些名称都无法告诉审查者，受保护请求究竟来自已知的代理运行，还是来自恰好使用同一运行时的另一条命令。

不要从审计记录中删除中间层。调查修改过的包脚本、意外的 `pre` 或 `post` 生命周期钩子、改变的 `PATH`，或用另一个可执行文件替代原程序的仓库脚本时，正是这些层最有价值。只有在将它们保留在不可变事件记录中后，才应在审批显示中压缩它们。

合理的显示规则是：

1. 首先放置请求方可执行文件和参数。
2. 当直接启动器会改变理解时显示它，例如 `npm run deploy-agent` 或 `make migration-bot`。
3. 将第一个可识别的已签名祖先进程标为启动权限。
4. 在事件详情中保留完整的已观察祖先链。

这条规则可以避免两个坏习惯。第一个是隐藏所有包装器，让修改过的任务变得不可见。第二个是在审查者回答请求之前，先展示七行进程树。面对难以阅读的审批卡，人们审批的是卡片的形式，而不是具体操作。

我不赞成一个常见建议：由于任务名称更容易被人识别，就审批任务运行器而不是代理。任务名称通常只是仓库文本。任何可以修改检出内容的人，都可能修改任务的实际行为。可以将任务名称作为上下文，但应将审批会话绑定到观察到的代理进程运行，并在新的运行开始时重新评估。

## 仔细选择签名边界

最近的已签名祖先进程通常是最好的可识别权限，但“最近”需要判断。一个已签名的系统 shell 或解释器可能直接位于未签名脚本上方。把 shell 称为权限，会得到一个技术上已签名、却无法说明代码由谁提供的标签。

Apple 的代码签名指南区分可执行文件标识符和签名身份，并使用 designated requirement 建立代码身份。`codesign` 工具可以显示该要求。使用输出识别已签名的应用或 helper，但不要把单独的标识符字符串当作发布者关系的证明。

对于捆绑应用，应检查祖先链中出现的可执行文件或应用包：

```sh
codesign --display --verbose=4 --requirements - "/Applications/Example.app" 2>&1 \
  | sed -n '/Identifier=/p;/TeamIdentifier=/p;/Authority=/p;/designated =>/p'
```

典型结果包含如下形式的字段：

```text
Identifier=com.example.editor
TeamIdentifier=AB12CDE345
Authority=Developer ID Application: Example Software (AB12CDE345)
designated => anchor apple generic and identifier "com.example.editor" and certificate leaf[subject.OU] = "AB12CDE345"
```

具体字段会因签名和分发路径而变化。重要的是，当 macOS 能提供稳定的代码身份时，审批系统应捕获它，然后以方便审查者理解的形式呈现，同时不隐藏原始证据。

选择显示权限时可以遵循以下规则：

- 优先选择实际位于请求方祖先链中的已签名图形应用或专用已签名 helper。
- 不要用通用的已签名解释器替代它执行的未签名脚本。
- 不要根据可执行文件目录、仓库名称或 shell 提示符推断权限。
- 清楚标记未签名和临时签名阶段，不要把它们埋在可识别父进程下面。
- 如果不存在可识别的已签名权限，就明确说明这次运行没有经过验证的本地发布者身份。

从概念上说，一个进程可能有多个签名：应用包签名、嵌套 helper 的签名，以及解释器的签名。应检查实际出现在进程列表中的可执行文件。已签名应用可以启动另一个单独签名的 helper，helper 也可以启动用户安装的二进制文件。每一层都是独立的身份问题。

## 某些启动链应该降低可信度

干净的父子链并不总是存在，而缺失的情况也不是无关紧要的边缘问题。审批系统最容易在这些地方犯下严重的归因错误。

### 远程执行会打断本地祖先关系

如果本地代理调用 SSH，你的 Mac 可以识别本地 SSH 客户端及其父进程。但远程命令在另一台主机上拥有独立的进程树。除非你在远程机器上收集并验证证据，否则不要说远程的 `agent-cli` 是由本地 IDE 启动的。

本地审批卡仍然有用。它可以标明本地请求方、目标地址、已知的远程账户，以及传给 SSH 的完整命令。对于决定本地代理是否可以发起连接，这些信息已经足够。但它不足以建立远程进程身份。

### 脱离启动器的工作需要新的身份决定

子进程可能调用 `setsid`、进行两次 fork，或要求 supervisor 在原始启动器退出后继续工作。一旦发生这种情况，只绑定到终端或初始 shell 的审批就成了虚构。脱离启动器的 worker 需要自己的进程记录、自己的启动时间，以及新的审批范围，除非你设计了审查者可以检查的明确 supervisor 关系。

### 环境继承可能改变操作方，却不改变进程树

同一个 `node` 路径可能因为 `PATH`、`NODE_OPTIONS`、语言特定的模块路径、代理设置或工作目录发生变化，而加载不同的代理包。进程祖先关系无法显示所有继承值。应为调查捕获受限的上下文指纹：可执行文件路径、参数、当前目录、经过脱敏的指定环境名称和值，以及适合你的模型的可执行文件摘要。

不要把完整环境导入审批卡或日志。环境中经常包含令牌、端点和个人路径。目标是区分不同运行，而不是在日志中再制造一个秘密存储。

## 审查者需要简短声明和可检查证据

审批提示只有几秒钟来赢得注意。先把与决策有关的声明放在顶部，然后为审查者提供一条路径，让他们检查系统为何做出这个声明。

一张实用的卡片可能是这样：

```text
Agent process requests an HTTP call

Requester
agent-cli --workspace /Users/maya/src/payments
PID 81234, started 14:08:31

Launched through
npm run agent > node scripts/launch-agent.js
from a signed editor process

Destination
api.example.internal / POST /deployments
```

它足够简短，便于阅读，也足够具体，能够提出质疑。审查者可以因为工作区不对、任务运行器出乎意料、目标地址异常，或请求来自未签名阶段而拒绝。它们都是阻止操作的真实理由。

将完整证据放在卡片后面。保留请求方 PID 和启动时间、可执行文件路径、参数、父进程链、所选权限的签名信息，以及审批决定。如果同一代理稍后在获批运行中发起另一个请求，记录应明确说明会话决定适用于该进程的生命周期，而不是适用于脱离进程的某个应用名称。

Sallyport 的按会话授权正是围绕这个实际边界构建的：新的代理进程首次调用时请求审批，审批只持续到该进程退出。它的审批卡首先显示进程的代码签名权限，为审查者提供一个可识别的起点，同时不假装签名能够解释每一条子命令。

## 按进程生命周期撤销，然后明确处理例外

会话审批应跟随代理进程的生命周期，因为真正发出请求的单位是进程。终端仍然打开、IDE 窗口仍然可见，或任务名称相同，都不应成为复用审批的理由，否则审批范围就无法诚实地检查。

这条规则确实会给短生命周期包装器带来摩擦。包管理器可能为每个子命令启动一个新的代理进程。不要因此悄悄把审批扩大到 npm 的所有未来子进程，或终端中的所有进程。应决定工作流是否需要稳定的已签名代理宿主、暴露单一清晰身份的长期进程，或对敏感凭据逐次确认。

当风险在于每次使用，而不是启动身份时，逐次审批才是正确选择。生产部署令牌、能够连接敏感主机的 SSH 私钥，或具有写入能力的管理 API，即使进程身份熟悉，也可能值得每次都由人决定。识别进程可以减少混乱，但不能替代判断。

实现测试很简单：结束已审批的进程，再次启动同一命令，确认第二次运行需要自己的审批。然后在同一个终端标签页中启动不同命令，确认它不能继承第一次运行的决定。如果任一测试失败，系统审批的就是一个位置或标签，而不是一个操作方。

审查者不应该被迫把终端名称当作进程身份的替代品。保留链条，识别发起请求的运行，显示你能够证明的最强可识别权限，并让缺失的证据保持可见。这比笼统的“可信 IDE”规则不那么华丽，但当任务脚本、shell 包装器或远程跳转最终成为关键因素时，它才能真正经得起检查。
