孤儿代理子进程可能让凭据保持有效
超时后,孤儿代理子进程仍可能让凭据和连接保持有效。了解进程组清理、运行状态撤销和审计方法。

超时的 AI 代理不一定真的停止了。如果它的 shell 包装器、编译器、HTTP 辅助工具或 SSH 命令在超时触发前派生了子进程,那个子进程可能在父进程 PID 消失后继续运行。如果它继承了可用凭据或已经完成认证的连接,那么这次超时并没有结束你以为已经撤销的权限。
我见过一些事件记录,开头写着「代理在 14:03 被结束」,结尾却出现了 14:11 发出的 API 调用。通常没有人发现什么高明的漏洞。运行器只结束了一个进程,而真正有用的工作早已转移到了另一个进程中。在自主代理能够使用生产凭据之前,进程清理听起来只是底层维护。到了那时,底层维护就是安全边界的一部分。
父进程退出,不代表工作结束
子进程可以活得比父进程更久,因为内核会独立跟踪进程,而不会把它们当作 shell 命令的临时延伸。父进程退出后,操作系统会将其子进程重新交给系统回收进程或监管器。子进程会保留自己的 PID、内存、文件描述符、套接字、当前目录,通常也会保留自己的环境。
这种行为本身是合理的。构建系统会使用它,终端复用器会使用它,服务管理器也依赖它。真正的问题在于,把父进程 PID 当成代理运行的执行边界。
来看一个常见的进程链:
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 关系可能已经无法准确说明问题,另一次运行也可能已经开始。
一个实用的超时处理流程包括五个动作:
- 在启动代理前创建专用进程组,或创建由监管器拥有的任务。
- 在一条记录中保存运行 ID、PID、PGID、启动时间、命令和截止时间。
- 超时后,先将运行标记为已过期,再发送信号,这样新操作不会被误认为是获批工作。
- 向已记录的隔离单元发送
TERM,等待一小段明确规定的宽限时间,然后只向仍然存活的进程发送KILL。 - 保存结果快照,并记录信号发送时间、退出状态和仍然存活的 PID。
之所以要先写入记录,是因为清理可能与进程快速退出同时发生。如果日志里只有「已结束代理」,你就无法回答究竟结束了哪个进程、哪些子进程与它共享进程组,也无法知道某个子进程是否在信号发出前逃逸。
在 macOS 上,应检查这些标识符,不要盲目信任进程树显示。下面的命令会显示大多数超时故障所需的字段:
ps -axo pid,ppid,pgid,sid,lstart,etime,command
清理前,一个健康的已过期运行可能会留下类似这样的输出:
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 向进程组发送信号:
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 系统上有意为子进程创建新的进程组和会话。对于必须在启动器退出后继续运行的桌面应用,这种行为可能很有用。但在代理执行器中,它是一个糟糕的默认设置,因为它会绕过执行器通常使用的进程组终止机制。
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 上,应使用已记录的编号过滤进程表,而不是猜测可执行文件名称:
ps -axo pid,ppid,pgid,sid,etime,command | awk '$3 == 4102 || NR == 1'
对于这条命令的布局,第一列是 PID,第二列是 PPID,第三列是 PGID。在生产代码中,如果语言可以调用原生进程 API,就不要解析面向人类的文本。在操作员终端中,这个输出可以帮助你在选择信号前快速进行清晰检查。
然后向进程组发送信号,再次检查:
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 上,专用子进程组、范围明确的工具权限,以及清晰的远程任务契约,通常是基础配置。如果你需要资源上限或完整的后代进程隔离,就使用真正提供这些控制能力的执行环境。
审计必须在父进程退出后继续存在
你无法从代理的最终文本中重建一个逃逸操作。父进程可能在刷新日志前就已经退出,而继续运行的子进程没有理由主动汇报。审计应该建立在代理进程之外观察到的不可变事件之上。
在允许外部工作之前,每次运行至少要记录以下字段:
{
"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 值、私钥、授权请求头或完整请求正文。一边记录凭据的使用,一边暴露凭据本身,会制造第二起事件。
发生超时时,要在发送任何信号前追加一次状态转换:
{
"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 测试装置展示了问题的结构:
#!/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 客户端可能已经启动了远程命令。应将取消请求、观察到的本地退出,以及外部系统返回的任何确认,作为不同事件分别记录。「已取消」听起来更让人安心,但这些记录能告诉操作员实际知道了什么。
我会在代理执行器中首先做一个小而朴素的修复:启动时创建并记录专用进程组,在超时流程中终止整个进程组,并在失败测试中断言结果。同时配合一个操作边界,在运行过期后拒绝请求。当这两点都成立时,孤儿进程就会从一种模糊的担忧,变成一个可以隔离、可以解释的事件。
常见问题
AI 代理运行中的孤儿子进程是什么?
孤儿进程指原父进程已经退出,操作系统随后为它重新指定了父进程。它仍可能继续运行、保持网络连接,并使用已经接收到的凭据材料。父进程超时只能证明某一个 PID 已经退出。
结束 AI 代理时,它的子进程也会一起结束吗?
只有在运行器将代理放入隔离的进程组或类似的隔离边界中,并终止整个边界时,才会同时结束子进程。只结束父进程 PID,会留下独立派生的子进程。可以用一个在父进程退出后继续休眠的子进程来测试这一点。
代理超时时,应该使用进程组还是会话?
对于本地 macOS 命令树,可以使用进程组,并在超时后向负的进程组 ID 发送信号。会话可以提供更宽的边界,服务管理器或容器则能提供更强的资源控制。无论选择哪种边界,都要在工作开始前记录它的标识符。
孤儿进程在代理被结束后还能使用 API 密钥吗?
SSH 控制套接字、已打开的认证连接、继承的文件描述符或长期有效的 bearer 令牌,都可能在父进程退出后继续使用。解决方法是避免把原始凭据交给代理进程,并让每个外部操作都有明确且有限的有效期。撤销机制必须覆盖整个操作路径,不能只覆盖聊天会话。
如何在 macOS 上找到超时代理留下的子进程?
在 macOS 上,发送信号前,可以用 ps 检查 PID、PPID、PGID、SID、运行时间和命令行。重点查看 PPID 已变为 1 或其他监管进程、但 PGID 仍与已过期运行相同的进程。如果可能需要作为事件记录,请在清理前保存这份快照。
用 pkill 清理超时的 AI 代理安全吗?
不安全。名称匹配可能结束无关的编辑器、测试运行器或另一个代理运行,也可能漏掉已经更改可执行文件名称的子进程。应该向已记录的进程组或由监管进程管理的任务发送信号,而不是依赖名称匹配。
AI 代理执行审计应该记录哪些内容?
至少记录截止时间、进程组或任务标识符、父进程 PID、获批的操作身份,以及使用单调时钟记录的时间戳。还要记录每次外部调用、退出状态和清理信号。如果没有边界标识符,事后归因就只能靠猜测。
操作网关能防止孤儿进程泄露凭据吗?
只有在网关将凭据留在代理之外,并且独立于代理自身输出记录操作时,它才能发挥作用。你仍然需要操作系统级的隔离,因为孤儿进程可能继续使用已经获得的文件、CPU、套接字和数据。网关能记录哪些内容穿过了它的边界,却无法让糟糕的进程树自动消失。
发现孤儿代理进程后应该怎么做?
首先结束已记录的进程组,短暂等待后,再处理仍然存活的进程。然后保存剩余进程的快照,并将其启动时间、进程组、开放的网络活动和操作记录进行比对。如果无法确认逃逸进程已经失去访问权限,就轮换或撤销相关凭据。
如何测试自主编程代理的超时清理?
在 CI 中运行一次刻意触发失败的测试:让父进程创建子进程,让父进程超过超时时间,然后断言两个 PID 都已消失,并确认过期记录之后没有被接受的操作。针对 shell 包装器、语言运行时和 SSH 辅助工具分别重复测试,因为它们都可能增加新的进程层级。没有经过测试的清理流程,只能算一种希望。