# PID 重用会破坏代理会话归属

仅用于状态屏幕时，单独的 PID 尚可接受。但它不能作为审批、审计记录或携带凭据的代理会话背后的身份。操作系统会回收进程 ID。如果代理系统把被重新分配的数字当成同一个行为者，就可能把后来的进程关联到早先进程获得的审批上。

这类问题不需要攻击者拥有内核访问权限，也不需要什么罕见竞态。一个短生命周期的代理退出后，繁忙机器创建了足够多的进程并重新使用了它的 PID，而某个组件又通过 PID 重新连接调用、刷新显示，或解析旧的审计记录，这个问题就会出现。后来的进程甚至可以是普通软件。归属仍然是错误的，而一个会根据错误归属采取行动的审批系统，已经失去了审批本身的意义。

修复并不复杂，但需要改变术语。不要再把 PID 称为进程身份。保存一份捕获到的进程记录，其中包含 PID、启动时间、代码签名事实，以及在作出授权决定时收集的父级来源信息。之后每次比较都必须证明，活动进程仍然与这份记录一致。

## PID 标识的是槽位，不是进程生命周期

进程 ID 标识的是当前分配给内核进程条目的编号。它不会永久指向某个程序，也不保证跨时间全局唯一。进程退出后，其 PID 就可能被重新分配。

只要系统允许之前的审批继续生效，这个区别就不再是吹毛求疵。假设一个代理客户端以 PID 4812 启动，请求使用部署凭据，用户批准了这次运行。客户端退出。之后，另一个进程获得 PID 4812。如果网关询问“PID 4812 是否有已批准的会话？”，它可能取回旧审批，并把它关联到新进程上。

危险之处在于，旧进程和新进程不必同时存在。很多实现是在数据库连接、缓存查询或延迟补充任务中犯下这个错误。它们把审批保存为 `pid = 4812`，也用同样的方式保存调用，然后相信之后的查询仍然代表当时的含义。事实并非如此。

PID 对面向人的记录仍然有用，因为它能帮助操作员检查活动系统。保留它即可。只是不要把它作为授权或归属判断使用的主要身份。

还有第二个陷阱：`fork` 和 `exec` 会让简单解释变得不够准确。fork 创建一个拥有不同 PID 的新子进程。exec 则在保留 PID 的情况下替换现有进程中的程序映像。如果你审批了一个 shell runner，而它随后 exec 另一个二进制文件，PID 看起来仍然熟悉，但下一次请求所使用的代码可能已经不同。身份设计必须同时考虑退出后的 PID 重用，以及存活进程中的程序替换。

## 在会话开始时捕获进程记录

可靠的会话记录是在安全边界处取得的快照，不是事后重新拼凑出来的一组事实。代理进程首次请求创建会话时就捕获它，并在展示描述调用方的审批之前完成捕获。

对于 macOS 代理网关，我会把以下字段作为一份不可变的进程快照保存：

- PID 和有效用户 ID。
- 进程启动时间。平台能提供秒和微秒时应一并保存。
- 捕获时观察到的可执行文件路径。
- 签名标识符、团队或签名者信息，以及可用时的代码目录哈希。
- 父进程快照，包括父进程自己的 PID 和启动时间。

进程启动时间会把 PID 转化为只对应某个生命周期的引用。一个实际的元组可以是：

```text
process_instance = (
  pid = 4812,
  start_time = 2026-07-22T14:03:18.482911Z,
  euid = 501
)
```

这个元组回答的是一个狭窄问题：“这是不是我之前看到的同一个内核进程生命周期？”它不能回答进程是否可信。签名记录回答的是另一个问题：“我检查这个进程时，macOS 验证的是什么代码？”父进程快照回答的又是另一个问题：“在会话开始时观察到，是什么创建了这个进程？”

不要把这些问题压缩成 `Claude Code (PID 4812)` 这样的单一文本标签。这个标签用于审批卡片可能没问题，但它会丢掉审阅者需要的事实，无法解释某个会话为什么获得授权。

在 macOS 上，进程检查器可以使用 `proc_pidinfo` 和 `PROC_PIDTBSDINFO` 获取 `proc_bsdinfo`，其中包含 `pbi_start_tvsec` 和 `pbi_start_tvusec`。Apple 也在其 Endpoint Security 进程模型中提供进程 `start_time`。关键不在于选择哪个 API，而在于你要在决策边界捕获启动时间，并在之后进行比较。

用于读取生命周期信息的精简 C 辅助函数可以这样写：

```c
#include <libproc.h>
#include <sys/proc_info.h>
#include <cstdio.h>

int read_process_lifetime(pid_t pid) {
    struct proc_bsdinfo info = {0};
    int size = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0,
                            &info, sizeof(info));
    if (size != sizeof(info)) {
        return -1;
    }

    printf("pid=%d start=%lld.%06d parent=%d uid=%d\n",
           info.pbi_pid,
           info.pbi_start_tvsec,
           info.pbi_start_tvusec,
           info.pbi_ppid,
           info.pbi_uid);
    return 0;
}
```

输出形式比语言绑定更重要：

```text
pid=4812 start=1784738598.482911 parent=4760 uid=501
```

如果之后读取到的 PID 相同，但启动时间戳不同，你看到的就是另一个进程。即使所有便利层都想把这个数字当成熟悉的编号，也要拒绝会话匹配。

## 代码签名描述的是代码，不是某个运行实例

代码签名数据能提供有用的来源信息，但不会自动变成进程身份。签名标识符可能被应用的每个发布版本共享。团队标识符标识的是签名组织，而不是某个特定可执行文件。代码目录哈希更接近具体的签名代码内容，但同时运行同一个可执行文件的多个实例仍会共享它。

Apple 的代码签名文档指出了一个常被安全产品混淆的重要区别。多个签名者可能声明同一个签名标识符，因此 Apple 建议检查验证类别；对于非 Apple 代码，还应检查团队标识符。Apple 的技术说明 TN3127 也解释了，指定要求会把标识符和签名要求结合起来，从而在更新过程中建立代码身份。

正确的理解方式是：签名事实告诉你 macOS 验证了什么可执行文件，以及它是否满足你的信任要求。它不能告诉你，这是不是五分钟前用户审批过的同一个进程。

对于会话授权，应同时使用两层信息：

```text
same_process_lifetime:
  pid, start_time, euid all match the captured record

same_expected_code:
  captured signing requirement still validates for the live process

same_session:
  the session token refers to this captured process record, not only its PID
```

不要只比较显示名称、bundle 标识符或路径。路径会变化。未签名的命令行工具可能只有很弱的本地身份属性。用户也可以从其他位置运行一个熟悉工具的副本。审批界面可以显示易懂的名称，但授权代码应保留那些经过评估、并最终产生该名称的签名事实。

一种常见的错误建议是只按签名标识符授权，因为它能跨应用更新保持连续。这种做法很流行，是因为更新连续性看起来很方便。但它不适用于按运行实例审批。如果用户批准了一个正在运行的代理进程，那么之后再次启动同一个签名程序也是一次新的运行，应获得新的会话决定。稳定的代码身份可以让审批卡片更容易理解，但不能把一次运行的审批悄悄扩展到未来所有实例。

## 只有保留父级生命周期，父 PID 才能作为证据

父进程数据能帮助审阅者了解代理是如何启动的。它可以区分从终端启动的客户端、由编辑器启动的进程、调度器启动的进程，或另一个代理启动的进程。但 `ppid = 4760` 和子进程 PID 一样存在重用问题。

错误模式很容易看出来：

```text
session.agent_pid = 4812
session.parent_pid = 4760
```

三小时后，审计查看器把 PID 4760 解析为当前拥有这个数字的进程，并把它标成该会话的父进程。原来的父进程可能早已退出。另一个进程现在拥有 4760。审计页面把历史声明变成了实时查询，并悄悄改写了历史。

应当把父进程快照和子进程快照一起捕获：

```text
parent_instance = (
  pid = 4760,
  start_time = 2026-07-22T14:01:02.117604Z,
  euid = 501,
  executable_path = "/usr/bin/login",
  signing_requirement = "captured evaluation",
  relationship = "observed_parent_at_session_open"
)
```

最后这个关系字段看似普通，却能避免很多不准确的表述。父进程记录表示“系统观察到子进程时，它是直接父进程”。它不表示“这个父进程授权了子进程”“这个父进程永远拥有子进程”，也不表示“它现在仍然是当前父进程”。这些说法需要独立证据。

如果你有 Endpoint Security 进程事件，Apple 的 `es_process_t` 会提供原始父 PID，并暴露 `parent_audit_token` 和 `responsible_audit_token`，以及启动时间和签名数据。API 能提供时，审计令牌比原始 PID 更合适，因为它保留了更多上下文。不过，仍应把事件中的进程对象视为一次捕获到的观察，不要用之后的 PID 查询替代它，再把两者称为等价信息。

没有 Endpoint Security 时，应使用可用的最佳进程 API，尽快完成捕获，并明确呈现不确定性。读取子进程和读取父进程之间，父进程可能退出。不要在这个竞态中假装确定。如果无法取得父进程，就将其标记为不可用或不完整，保留已经捕获的子进程身份，并避免声称你无法观察到的父子关系。

## 已审批会话执行操作前重新验证

会话审批需要两个身份处理时刻：会话打开时捕获，使用权限时重新验证。一次性捕获能保护审计记录，重新验证能保护下一次操作。

设想以下过程：

1. 代理进程打开会话，你捕获 PID 4812、启动时间、签名事实和父进程信息。
2. 用户批准这次确切的代理运行。
3. 进程退出，而会话令牌仍留在内存或本地 IPC 连接中。
4. 后来的进程获得 PID 4812，并提交过期令牌，或因为缺陷访问了旧关联。
5. 网关在执行需要凭据的调用前检查活动进程。

最后一次检查时，PID 可能相同，但启动时间不会相同。这个不匹配必须关闭会话。不要用新的时间戳修补记录，不要悄悄创建替代会话，也不要把操作归属到早先的进程。

检查失败时也应拒绝继续。如果因为进程退出、权限变化或操作系统返回不完整数据，你无法读取进程信息，就无法证明请求者是已审批的进程。正确做法是要求建立新会话。

比较应当狭窄且直接。不要使用“命令名称相同”“工作目录相同”或“终端窗口相同”之类的模糊匹配。这些字段有助于人类理解上下文，却无法抵御恶意或意外的歧义。进程可以改变工作目录，不相关的两个进程也可以选择同一个命令名称。

应缓存附着于已捕获进程实例的授权决定，而不是缓存 PID 到审批状态的映射。按 PID 建立的映射在安静的笔记本上可能通过测试，却会在进程频繁创建和退出时失败，因此一旦到达用户环境，往往很难诊断。

## 把会话建模为不可变事件，而不是可变进程记录

会话日志应保留网关在每个时间点观察到的内容。一条始终写着“PID 4812 处于活动状态”的可变记录，无法回答字段来自原始代理、替代进程，还是晚到的后台刷新。

使用带有明确引用的追加式事件记录。结构可以很简单：

```json
{
  "event_type": "session_authorized",
  "session_id": "sess_7d9f",
  "process_instance": {
    "pid": 4812,
    "start_time": "2026-07-22T14:03:18.482911Z",
    "euid": 501,
    "signing_id": "com.example.agent",
    "team_id": "A1B2C3D4E5",
    "cdhash": "captured-code-directory-hash"
  },
  "parent_instance": {
    "pid": 4760,
    "start_time": "2026-07-22T14:01:02.117604Z"
  },
  "approval": {
    "scope": "this process run",
    "decision": "approved"
  }
}
```

下一条调用事件引用 `sess_7d9f`，并记录自己的时间、请求的通道、操作目标和结果。它不会只复制 `pid: 4812`，然后希望分析人员能还原其余信息。如果重新验证失败，就用观察到的不匹配写入单独的拒绝事件。

```json
{
  "event_type": "action_denied",
  "session_id": "sess_7d9f",
  "reason": "process_start_time_mismatch",
  "captured_pid": 4812,
  "captured_start_time": "2026-07-22T14:03:18.482911Z",
  "observed_start_time": "2026-07-22T15:47:09.031882Z"
}
```

这样审阅者能看到具体事实：进程编号被重新使用，网关识别出了不匹配，并拒绝了请求。没有两个时间戳，事件只能说明“某件事失败了”。当你需要区分软件缺陷和试图继承权限的恶意行为时，这远远不够。

Sallyport 将 Sessions 日志和 Activity 日志分开，这一点在这里很有用，因为会话级授权和单个操作回答的是不同的审计问题。两者都需要保留会话决定作出时存在的进程快照，而不是在之后的投影或审查中依靠单独的 PID。

## 审批卡片应描述来源，但不要假装确定

用户无法根据只写着“代理请求访问权限”的对话框作出良好决定。他们需要足够的身份上下文来识别调用方，但卡片也不应堆满看似精确、实际信息量很少的字段。

首先展示你评估过的代码签名主体。在有帮助时加入可执行文件名称或路径。只有在捕获了支持这些说法的数据时，才用简单语言展示进程关系，例如“由已签名的终端应用启动”或“从编辑器辅助进程启动”。将 PID 作为排查细节显示，而不是把它当作身份。

不要把父进程链呈现得像证书链。父子关系是操作系统在某个时间点的观察。已签名的父进程可能启动未签名的子进程。预期的父进程也可能只是一个随后 exec 其他程序的包装器。审批卡片应说明现在是谁在请求会话，然后把父进程信息作为上下文。

这一区别也会影响撤销方式。撤销会话标识符，而不是 PID。用户撤销会话时，将不可变会话记录标记为已撤销，并拒绝之后引用它的操作请求。即使原始进程仍在运行，它也会失去访问权限。如果它已经退出且 PID 被重新使用，撤销仍然正确，因为它从未依赖于控制这个数字 PID。

一次审批应代表一次观察到的运行。如果用户需要更广泛的信任决定，就构建单独且明确的功能，并清楚声明范围，例如指定的签名代码要求和明确的有效时长。不要因为实现恰好有一个方便的 PID 缓存，就把更宽的范围偷偷塞进单次会话审批。

## 测试失败场景，不要相信正常的进程行为

PID 重用问题之所以隐蔽，是因为普通手动测试不会产生足够多的进程更替。测试套件应迫使代码证明：它会拒绝继承某个编号的替代进程，也会拒绝在存活进程中通过 exec 更换代码后的进程。

不必等待 macOS 自然重新使用某个特定 PID。把进程检查层放在接口后面，然后向它提供受控快照。一个好的测试包含已审批记录，以及之后具有相同 PID 但启动时间不同的活动观察：

```text
captured: pid=4812 start=1784738598.482911 signer=team-A
observed: pid=4812 start=1784744029.031882 signer=team-B
expected: deny with process_start_time_mismatch
```

再加入一个 PID 和启动时间都相同、但签名要求失败的案例。还应加入一个子进程仍然存活、但 exec 已经改变可执行文件事实的案例。这些测试能证明授权代码连接了正确字段，而不是仅仅证明进程检查可以返回数据。

单独测试审计视图。为旧会话写入一个父 PID，模拟父进程退出，再提供一个拥有同一 PID 的无关后续进程。渲染出的历史会话必须保留最初捕获的父进程字段，不能用当前进程表中的名称替换它们。

最后，测试取消和检查失败。进程可能在接受 IPC 请求和查询详细信息之间的短暂间隔内消失。代码应记录清楚的拒绝原因，并要求调用方建立新会话。把这些失败转换成尽力匹配的系统，正好制造了这个身份模型要消除的歧义。

## 有用的不变量很简单，也很严格

每个需要特权的代理操作都必须引用一个绑定到单一进程生命周期的会话。活动请求若要匹配，就必须证明它来自同一个生命周期，而且其可执行文件仍满足已审批的代码身份。PID 被重新使用会无法通过第一项检查。PID 未变但背后的二进制文件不同，则无法通过第二项检查。

这个不变量让每项进程事实各司其职。启动数据区分生命周期。签名数据标识经过验证的代码。父进程信息提供来源。会话 ID 携带人的决定。任何一个字段都不能代替其他字段完成工作。

Sallyport 可以通过优先展示进程的代码签名主体，并分别保存运行和调用记录，让这个决定清晰可见。但实现仍应拒绝让一个在日志中看似熟悉的数字，代替真正获得审批的进程。

下次看到以 `pid` 为键的表时，问问进程退出后会发生什么。如果答案是“我们再查一次”，那它就不是会话身份存储。应在一次无害的进程生命周期事件变成授权错误之前修复它。
