# 孤儿代理子进程可能让凭据保持有效

超时的 AI 代理不一定真的停止了。如果它的 shell 包装器、编译器、HTTP 辅助工具或 SSH 命令在超时触发前派生了子进程，那个子进程可能在父进程 PID 消失后继续运行。如果它继承了可用凭据或已经完成认证的连接，那么这次超时并没有结束你以为已经撤销的权限。

我见过一些事件记录，开头写着「代理在 14:03 被结束」，结尾却出现了 14:11 发出的 API 调用。通常没有人发现什么高明的漏洞。运行器只结束了一个进程，而真正有用的工作早已转移到了另一个进程中。在自主代理能够使用生产凭据之前，进程清理听起来只是底层维护。到了那时，底层维护就是安全边界的一部分。

## 父进程退出，不代表工作结束

子进程可以活得比父进程更久，因为内核会独立跟踪进程，而不会把它们当作 shell 命令的临时延伸。父进程退出后，操作系统会将其子进程重新交给系统回收进程或监管器。子进程会保留自己的 PID、内存、文件描述符、套接字、当前目录，通常也会保留自己的环境。

这种行为本身是合理的。构建系统会使用它，终端复用器会使用它，服务管理器也依赖它。真正的问题在于，把父进程 PID 当成代理运行的执行边界。

来看一个常见的进程链：

```text
agent-runner (PID 4102)
  shell tool wrapper (PID 4131)
    deployment script (PID 4140)
      ssh helper (PID 4144)
```

运行器到达截止时间后，向 PID 4102 发送信号。如果 shell 或部署脚本没有随之退出，剩余的进程链就可能继续运行。更麻烦的情况是，脚本使用 `&` 启动后台任务，使用 `nohup`，调用服务管理器，或者要求远程主机启动工作。父进程可能已经正常退出，但子进程早已独立运行。

所以，孤儿代理子进程不只是一个遗留的 CPU 消耗者。它是一个人或调度器认为已经结束的决策，在无人观察的情况下继续执行。损害范围取决于这个进程仍能访问什么，但故障更早就开始了：运行器选择了错误的停止单位。

POSIX 在进程和作业控制接口中说明了这种行为的基础。进程组是一组相关进程，共享一个进程组 ID。信号可以发送给整个进程组，而不是单个成员。会话可以包含一个或多个进程组，通常还与控制终端有关。这些是不同的内核概念，把它们混为一谈会产生薄弱的清理代码。

## 超时必须终止一个隔离单元

只有在代理启动前就建立了隔离单元，超时处理才安全。对于本地命令树，这个单元通常是一个专用进程组。运行器会立即记录进程组 ID，截止时间到达后，再向整个进程组发送信号。

顺序很重要。不要等到清理时才去查找子进程。那时，原始父进程可能已经消失，PPID 关系可能已经无法准确说明问题，另一次运行也可能已经开始。

一个实用的超时处理流程包括五个动作：

1. 在启动代理前创建专用进程组，或创建由监管器拥有的任务。
2. 在一条记录中保存运行 ID、PID、PGID、启动时间、命令和截止时间。
3. 超时后，先将运行标记为已过期，再发送信号，这样新操作不会被误认为是获批工作。
4. 向已记录的隔离单元发送 `TERM`，等待一小段明确规定的宽限时间，然后只向仍然存活的进程发送 `KILL`。
5. 保存结果快照，并记录信号发送时间、退出状态和仍然存活的 PID。

之所以要先写入记录，是因为清理可能与进程快速退出同时发生。如果日志里只有「已结束代理」，你就无法回答究竟结束了哪个进程、哪些子进程与它共享进程组，也无法知道某个子进程是否在信号发出前逃逸。

在 macOS 上，应检查这些标识符，不要盲目信任进程树显示。下面的命令会显示大多数超时故障所需的字段：

```sh
ps -axo pid,ppid,pgid,sid,lstart,etime,command
```

清理前，一个健康的已过期运行可能会留下类似这样的输出：

```text
  PID  PPID  PGID   SID  STARTED                  ELAPSED COMMAND
 4102  3988  4102  4102  Thu Jul 24 14:00:02 2026   00:31 agent-runner ...
 4131  4102  4102  4102  Thu Jul 24 14:00:02 2026   00:31 /bin/sh -c ...
 4144  4131  4102  4102  Thu Jul 24 14:00:04 2026   00:29 ssh ...
```

这些数值只是示例，不要把它们硬编码为固定模式。重要的是，这次运行有一个明确的 PGID，也就是 4102。清理时应向这个进程组发送信号，而不是只向 PID 4102 发送信号。负数目标会让 `kill` 向进程组发送信号：

```sh
kill -TERM -4102
```

请根据所用的语言运行时和操作系统验证语法。有些包装器对负数解析不正确，或者会把额外参数当成命令选项。不要围绕一条从未在真实超时条件下测试过的命令行建立安全边界。

向进程组发送信号仍有局限。子进程可以调用 `setsid`，创建新的进程组，把工作交给另一个服务，或者在远程机器上启动进程。进程组管理能堵住常见的本地逃逸路径，却不能防住所有可能的逃逸。运行器必须把这些转移视为明确的交接，并为它们建立独立的取消和审计机制。

## 进程组、会话和凭据解决的是不同问题

进程组为你提供本地信号目标。会话可以提供更宽的边界，并隔离终端作业控制。它们都不能撤销进程已经复制到内存中或写入磁盘的凭据。这些是不同的控制措施，也需要不同的证据。

之所以容易混淆，是因为故障看起来像一个事件：代理超时了，稍后却又有调用成功。清理层要问的是：「哪些本地进程应该停止？」凭据处理要问的是：「哪些进程仍然可以授权这个外部操作？」审计要问的是：「我们能否证明批准结束后发生了哪个操作？」进程组只能回答第一个问题。

环境变量是最典型的糟糕交接方式。如果运行器导出了 `API_TOKEN`，每个后代进程都会继承它，除非后续进程主动清除环境。子进程可以复制令牌、传给另一个程序，或者保持已经认证的连接。父进程启动后再替换环境变量，并不能收回已经继承的字节。

文件和套接字同样重要。进程可能继承一个打开的文件描述符，它指向凭据文件、带有会话状态的 HTTPS 连接，或 SSH agent 套接字。父进程退出后，后台进程仍可以继续使用这个描述符。close-on-exec 有助于防止描述符在新的 `exec` 中被意外继承，但如果子进程已经拥有描述符，或程序有意将它继续传递出去，这个机制就不起作用。

SSH 需要格外谨慎。加载到本地辅助工具中的私钥、SSH agent 套接字、多路复用的控制连接，以及远程命令，是四种不同的生命周期。结束本地父进程后，已有连接或远程命令仍可能继续运行。如果代理可以任意调用 SSH，运行器就需要知道它授权的是本地辅助工具调用、已认证通道，还是远程任务。把这三者都叫作「SSH 命令」，会掩盖你真正需要控制的边界。

更安全的模式是传递操作请求，而不是原始密钥。进程向权限组件请求执行某个特定的 HTTP 请求或 SSH 操作。这个组件持有密钥，决定当前运行是否可以使用它，执行操作并返回结果。逃逸的子进程在运行仍获授权期间，仍可能重复发送请求，所以权限组件也必须理解过期和撤销。

Sallyport 对支持的 HTTP 和 SSH 通道采用了这种分离方式：代理通过 MCP shim 发起请求，由应用持有 API 或 SSH 材料并执行操作，而不是把密钥传入代理进程。这不能替代进程清理，但可以消除最容易发生的凭据继承。

## 后台运行是逃生通道，不是无害的 shell 细节

最常见的清理故障始于一个 shell 便利功能。有人写下 `command &`、启动管道、使用 `nohup`，或者启动会创建工作进程的语言运行时。父命令看起来已经结束或发生超时，但工作进程仍在继续。

Shell 管道尤其需要注意。运行器可能执行 `/bin/sh -c 'generator | uploader'`，并记录 shell PID。shell 会为管道两端分别创建子进程。根据运行器创建进程组的方式不同，只结束 shell 可能会留下 generator 或 uploader。如果 uploader 持有凭据和网络连接，那正是你需要停止的进程。

很多人低估了 `nohup` 的问题。它会阻止进程接收挂起信号，却不会让进程变得可信，也不会向运行器报告自己的存在。在自动化环境中，它通常意味着有人希望工作在终端或父进程退出后继续。这种做法对于受管理的服务可能合理，但任务必须转移到拥有该任务并记录其生命周期的监管器中。它不应该偶然出现在代理工具调用里。

脱离的子进程会带来另一类问题。在 Node.js 中，`spawn` 配合 `detached: true` 会在类 Unix 系统上有意为子进程创建新的进程组和会话。对于必须在启动器退出后继续运行的桌面应用，这种行为可能很有用。但在代理执行器中，它是一个糟糕的默认设置，因为它会绕过执行器通常使用的进程组终止机制。

```js
import { spawn } from "node:child_process";

const child = spawn("/bin/sh", ["-c", "sleep 600"], {
  detached: true,
  stdio: "ignore"
});
child.unref();
```

这段代码是清理测试用例，不是应该复制到代理运行器中的执行模式。它会创建一个不再与父进程共享通常进程组的子进程。如果工具框架可以运行这样的代码，那么单靠进程组清理就无法提供完整保证。限制脱离式执行，在工具边界拦截它，或者在更强的操作系统隔离环境中运行工具。

很多人推荐的「直接结束进程树」同样会失败，尤其是它指的是递归遍历 PPID。PPID 描述的是某个时刻的关系，不是持久的成员边界。重新分配父进程会改变它。子进程可能在遍历和发送信号之间完成派生。后代进程也可能离开这棵树。在启动时记录进程组或任务身份，然后把任何离开该身份的行为变成有意且可审计的操作。

## 不要按命令名称清理

`pkill` 和宽泛的名称匹配看起来很方便，因为命令很短。但它们不适合并发运行的代理。`pkill ssh` 可能会结束无关的交互式连接，`pkill python` 可能会结束开发者本地的工具。参数字符串匹配可能漏掉已经改变参数的进程，也可能误匹配另一次运行中的进程。

命令名称只能作为调查线索。强制执行时，应使用所有权边界。

知道进程组的运行器可以先检查其中的成员。在 macOS 上，应使用已记录的编号过滤进程表，而不是猜测可执行文件名称：

```sh
ps -axo pid,ppid,pgid,sid,etime,command | awk '$3 == 4102 || NR == 1'
```

对于这条命令的布局，第一列是 PID，第二列是 PPID，第三列是 PGID。在生产代码中，如果语言可以调用原生进程 API，就不要解析面向人类的文本。在操作员终端中，这个输出可以帮助你在选择信号前快速进行清晰检查。

然后向进程组发送信号，再次检查：

```sh
kill -TERM -4102
sleep 2
ps -axo pid,ppid,pgid,sid,etime,command | awk '$3 == 4102 || NR == 1'
```

如果仍有成员存在，先弄清楚原因，再条件反射式地发送 `KILL`。阻塞在不可中断内核工作中的进程，需要与忽略 `TERM` 的进程采用不同的调查方式。进程使用不同的 PGID 并不是偶然躲过了信号，它跨越了边界。这可能说明存在设计决策、框架行为，或者恶意工具。

规定的宽限期结束后，如果记录的进程组中仍有本应停止的本地进程，可以使用 `KILL`。代理已经超出权限时间窗口时，不要承诺优雅清理。宽限时间用于关闭文件和记录状态，不是为了让外部工作无限期继续。

在你控制执行环境时，容器和服务管理器可以比原始进程组提供更好的所有权。macOS 桌面代理工作流不会自动获得 Linux cgroups，所以不要假定 cgroup 建议可以原样适用。在 Mac 上，专用子进程组、范围明确的工具权限，以及清晰的远程任务契约，通常是基础配置。如果你需要资源上限或完整的后代进程隔离，就使用真正提供这些控制能力的执行环境。

## 审计必须在父进程退出后继续存在

你无法从代理的最终文本中重建一个逃逸操作。父进程可能在刷新日志前就已经退出，而继续运行的子进程没有理由主动汇报。审计应该建立在代理进程之外观察到的不可变事件之上。

在允许外部工作之前，每次运行至少要记录以下字段：

```json
{
  "run_id": "run-7f3c",
  "started_at": "2026-07-24T14:00:02Z",
  "deadline_at": "2026-07-24T14:00:32Z",
  "parent_pid": 4102,
  "process_group": 4102,
  "approval_identity": "signed agent process identity",
  "state": "active"
}
```

操作记录必须包括运行 ID、操作序列、网关接受请求的时间、通道、目标身份和结果。默认情况下，绝不要记录原始 bearer 值、私钥、授权请求头或完整请求正文。一边记录凭据的使用，一边暴露凭据本身，会制造第二起事件。

发生超时时，要在发送任何信号前追加一次状态转换：

```json
{
  "run_id": "run-7f3c",
  "event": "deadline_expired",
  "observed_at": "2026-07-24T14:00:32Z",
  "process_group": 4102,
  "signal": "TERM"
}
```

这样调查就有了一个明确的问题和明确的答案：`deadline_expired` 之后是否开始了外部操作？如果网关在该事件之后接受操作，要么是它没有执行运行状态检查，要么是调用者没有携带网关预期的身份。如果操作在过期前开始、过期后完成，也要明确说明。开始和完成是两个不同的时间戳，把它们合并会让正常的进行中工作看起来像一次逃逸。

在人类可读的记录中保留墙上时钟时间，同时使用单调的经过时间来排列同一台机器上的事件。墙上时钟可能因为同步或手动调整而改变。事件发生时你不需要听一堂计时学课程，只需要足够的数据，避免在时钟不一致时错误地断言某个操作发生在父进程退出之后。

Sallyport 的 Sessions 和 Activity 日志将运行视图与单独的调用分开，其加密哈希链日志还可以通过 `sp audit verify` 离线检查。父进程消失后，这一点很有用，因为审计记录不依赖代理主动提供自己的历史。

审计记录是证据，不是隔离措施。它可以告诉你某个调用已经发生，并支持后续审查，却不能结束本地孤儿进程，收回已经返回给它的数据，也不能取消没有取消路径的远程命令。把这些看作不同的职责，并在运行记录中分别展示。

## 按正确顺序重建故障

发现疑似孤儿进程时，应先保留事实，再清理现场。为了停止有害工作，直接使用 `kill -9` 可能是合理的，但它也可能抹掉修复运行器所需的进程关系。如果情况允许，先保存进程快照。

从已过期的运行记录开始。记下父进程 PID、PGID 或任务身份、启动时间、截止时间，以及发送过的每个信号。然后获取当前进程快照。在 macOS 上，对比 PID、PPID、PGID、SID、经过时间和命令。孤儿进程通常会有一个为 1 或某个监管器的 PPID，但不要只靠这一点判断。即使 PPID 仍指向其他位置，进程也可能继续构成风险。

接下来，判断逃逸属于哪一类：

- 进程仍在原始 PGID 中，却没有在预期信号后退出。
- 进程拥有新的 PGID 或 SID，因此已经在本地脱离。
- 本地进程发起了远程启动请求，远程任务继续运行。
- 运行过期后，某个凭据或已认证通道仍然可用。

每一类问题都需要不同的修复。第一类指向信号处理或清理时序问题。第二类指向未经批准的脱离机制。第三类需要远程任务 ID 和取消协议。第四类要求操作权限组件将请求绑定到当前运行，而不是相信父进程会一直按预期工作。

然后将操作记录与时间线进行比对。不要先从代理转录内容开始。查看过期后被接受的请求、与原始指令不同的目标、持续批处理的重复调用，以及父进程已经退出后它不可能报告的调用。稍后可以用转录内容解释意图，但它无法证明某个网络请求是否发生。

最后，决定凭据是否需要轮换或撤销。如果原始密钥没有进入代理，且操作网关拒绝了过期后的调用，那么剩余风险可能只限于已经完成的数据或变更。如果子进程继承了 bearer 令牌、私钥、SSH agent 访问权限或已连接的管理通道，就应假定它仍然可以继续操作，直到你禁用该权限。团队经常在这里浪费时间，争论进程「大概」有没有做什么。如果无法确认它已经失去访问权限，就移除访问权限。

## 失败测试应该让孤儿进程无处隐藏

超时测试工具需要一个在子进程存活时明确失败的测试。只测试父进程是否返回超时，几乎不能证明任何事情。

创建一个测试装置，让父进程和子进程在同一个已记录的进程组中启动。子进程应等待足够长的时间，让测试工具有机会使父进程超时。父进程应在阻塞前报告子进程 PID 和进程组。超时后，断言两个进程都不存在，并确认操作日志中没有在过期记录之后被接受的调用。

下面这个 shell 测试装置展示了问题的结构：

```sh
#!/bin/sh
(
  trap 'exit 0' TERM INT
  sleep 600 &
  child=$!
  printf 'parent=%s child=%s pgid=' "$$" "$child"
  ps -o pgid= -p "$$" | tr -d ' '
  wait "$child"
)
```

使用代理执行器实际采用的同一套启动代码运行它，不要从交互式终端启动，因为终端可能会悄悄提供不同的作业控制行为。捕获打印出的 ID，强制触发截止时间，然后检查进程表。如果子进程仍然存在，你就得到了具体结论：要么启动过程没有创建你以为会创建的进程组，要么超时信号没有发给这个进程组。

针对代理实际调用的工具重复测试。Shell 包装器、Node 子进程 API、Python subprocess 调用、包管理器、编译器和 SSH 辅助工具，对进程组、描述符和信号转发的处理方式各不相同。你不需要数百个测试，只需要为每种可能创建后代进程的启动模式准备一个破坏性测试。

再添加一个尝试脱离的测试装置。测试可以使用创建会话的调用，或使用能够创建脱离子进程的运行时选项。预期结果应该是拒绝、由监管器接管，或记录一次明确的注册事件。悄无声息地成功就是缺陷，因为这等于告诉代理，它可以把工作移到超时边界之外。

不要只关注本地 PID。如果代理可以启动远程工作，就让远端返回任务标识符，并测试本地超时后是否能够取消。理想状态不是「SSH 客户端退出了」，而是「由这次运行启动的远程任务已经停止，或者剩余工作已经有明确记录的负责人」。

## 权限应在进程之前过期

如果子进程可以在运行被宣布结束后继续使用批准的权限，那么代理运行的人类批准就失去了意义。将外部操作权限绑定到运行身份，并让权限组件检查每一个请求。运行器将运行标记为过期后，即使本地孤儿进程仍有 CPU 时间，来自该运行的新操作也必须失败。

这并不意味着每条 shell 命令都要询问人类。它意味着能够消耗权限的组件知道运行何时结束。这个组件可以为运行提供短期有效的句柄，在撤销后拒绝它，并记录拒绝结果。进程组终止可以限制本地损害，操作边界则可以限制清理竞态带来的后果。

需要逐项批准的操作仍然应保留逐项批准，但不要把批准与监管混为一谈。用户可能在会话开始时批准一个经过签名的代理进程。如果某个后代进程在代理退出后继续运行，即使它继承了相同的本地上下文，也已经超出了获批的执行生命周期。系统必须让这种情况可被强制执行，不能指望子进程自觉停止。

运行器还应区分取消运行和取消外部影响。被取消的请求可能已经到达 API。被结束的 SSH 客户端可能已经启动了远程命令。应将取消请求、观察到的本地退出，以及外部系统返回的任何确认，作为不同事件分别记录。「已取消」听起来更让人安心，但这些记录能告诉操作员实际知道了什么。

我会在代理执行器中首先做一个小而朴素的修复：启动时创建并记录专用进程组，在超时流程中终止整个进程组，并在失败测试中断言结果。同时配合一个操作边界，在运行过期后拒绝请求。当这两点都成立时，孤儿进程就会从一种模糊的担忧，变成一个可以隔离、可以解释的事件。
