Hotdry.

Article

Agent shell 工具:进程组隔离、超时升级杀与 stdout/stderr 管道背压

Coding Agent 与 MCP 终端类工具在生产中常因孙进程泄漏、管道写满死锁与无界输出拖垮 Worker。本文给出 setpgid/setsid 进程组、SIGTERM→SIGKILL 升级时序、并发读管道与字节/行截断的可落地表,并说明无法仅靠「kill 主 PID」解决的边界。

2026-07-14systems

当 Agent 暴露 run_terminalbashexecute_command 或 MCP 等价工具时,平台进程会把模型生成的命令字符串交给本机 shell 或直接 exec。与 HTTP 出站 SSRF、SQL 沙箱不同,这类工具的主要故障模式往往不只是「权限过大」,而是生命周期失控:超时后只杀了 shell 本身、孙进程继续占 GPU / 端口;或子进程把 stdout 写满内核管道后阻塞,父进程在等 waitpid 的同时未读管道,形成死锁。本文只讨论 POSIX/Linux 上子进程编排层 的可验证行为与推荐参数,不覆盖 seccomp/Landlock 文件沙箱,也不讨论「是否应该给 Agent 开 shell」的产品决策。

问题背景:终端工具把不可信程序接进了 Worker 的进程树

典型数据流:

  1. 编排器向模型注册 shell 工具,参数为 command / cwd / timeout_ms
  2. Worker 用 sh -c 或语言运行时的 spawn 启动子进程,把 stdout/stderr 接到管道。
  3. 输出字节序列化后回灌模型上下文;超时或用户取消时尝试终止命令。

在 Agent 场景下,下列因素会放大系统层边角:

  • 进程树深度不确定npm testdocker composemake -j 会再 fork 孙进程;若只对 shell 的 PID 发 SIGTERM,子树可能成为孤儿进程并被 init 收养,继续占用资源。
  • 管道容量有限:Linux 上匿名管道默认容量通常为 64 KiB 量级(可通过 fcntl(F_SETPIPE_SZ) 调整,受 /proc/sys/fs/pipe-max-size 上限约束)。子进程持续写 stdout 而父进程不读时,写端最终阻塞在 write(2)
  • 双管道交错:stdout 与 stderr 各一条管道时,若串行「先读完 stdout 再读 stderr」,当 stderr 先写满而父进程仍在阻塞读 stdout,同样会死锁。
  • 输出无界回灌:即使进程正常结束,find /cat 大文件、冗长测试日志也会把数 MB~GB 文本塞进下一轮 prompt,触发上下文溢出或极高 token 费用。
  • 取消语义模糊:用户点「Stop」时,产品期望停止整棵命令树;若未建独立进程组 / 会话,信号可能只打到中间层,或误伤 Worker 线程池中的其它任务(若错误地使用进程组 0 等语义)。

因此需要把防御拆成:进程组 / 会话边界超时与取消的信号升级并发排空管道 + 硬截断回灌给模型的结构化错误 四层。

可落地实现:进程组、超时升级与管道读策略

1. 把每次 tool 调用放进独立进程组(或会话)

目标:父进程持有一个 pgid(或会话首进程 PID),超时 / 取消时对整个组发信号,而不是只 kill(child_pid)

运行时 推荐做法 说明
POSIX C 子进程在 execsetpgid(0, 0);父进程记录 pgid 新建进程组,组首为该子进程
Python 3 subprocess.Popen(..., start_new_session=True) 文档说明其效果接近在子进程调用 os.setsid(),子进程成为新会话首进程
Node.js child_process.spawn(cmd, { detached: true, stdio: ['ignore','pipe','pipe'] })child.unref() 仅在「完全脱钩」场景使用 Agent Worker 通常不要 unref:需要保持引用以便超时杀与收尸;detached: true 主要用于新会话 / 进程组
直接 sh -c 仍应对 shell 进程 建组;组内再 fork 的子进程默认继承 pgid 杀组时覆盖 shell 拉起的子孙(除非子孙自行 setpgid/setsid 脱离)

父进程侧超时杀组(示意,信号编号以 Linux 为准):

/* pgid 为子进程进程组 ID;先温和再强制 */
kill(-pgid, SIGTERM);   /* 负 PID:向进程组发信号,见 kill(2) */
/* 等待 grace 后若仍有存活 */
kill(-pgid, SIGKILL);

Python 等价思路:

import os, signal, subprocess, time

def run_shell(command: str, timeout_s: float = 30.0, grace_s: float = 2.0):
    p = subprocess.Popen(
        ["bash", "-lc", command],
        start_new_session=True,  # 新会话 / 独立进程组
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    try:
        out, err = p.communicate(timeout=timeout_s)
        return p.returncode, out, err
    except subprocess.TimeoutExpired:
        os.killpg(p.pid, signal.SIGTERM)
        time.sleep(grace_s)
        try:
            os.killpg(p.pid, signal.SIGKILL)
        except ProcessLookupError:
            pass
        out, err = p.communicate(timeout=5)
        return None, out, err  # None 表示因超时终止

要点:

  • killpg(p.pid, …) / kill(-pgid, …) 要求调用方有权向组内成员发信号;Worker 与子进程通常同 uid,满足该条件。
  • 不要在多租户共享的同一进程组里混跑多个 tool 调用;每次调用独立 Popen + 独立 pgid。
  • 若必须用 sudo /setuid 包装,信号与权限模型会变化,需单独审计(见风险节)。

2. 超时与取消的推荐时序参数

参数 生产建议 说明
默认 timeout 30s(交互式排障可租户级调到 120s) 单次 tool 墙钟上限;与 LLM 步数配额正交
用户取消 → SIGTERM 立即 允许进程跑 atexit/ 清理句柄
SIGTERM 宽限 grace 1–3s 过长会拖住 Worker 槽位;过短则大量落成 SIGKILL
宽限后 SIGKILL 不可捕获;用于卡住的 D 状态之外的「拒不退出」进程
wait 收尸超时 grace + 5s 防止僵尸 PID 占满;若仍卡在不可中断睡眠,需运维侧处理
每 run 并发 shell 1–2 避免同一 Agent run 内多条无界流水线打满 CPU
每租户全局槽位 按机型核数设定(如 4–16) 与成本熔断、队列租约联动

取消路径应与超时共用同一「升级杀」状态机,仅触发源不同(AbortSignal / 上下文 cancel / 墙钟)。向模型返回时使用稳定机器可读码,例如 tool_timeouttool_cancelled,并附已捕获的截断输出前缀,便于模型决定是否重试更窄的命令。

3. 并发排空 stdout/stderr,避免管道死锁

错误模式(易在自研 Agent 中出现):

// 伪代码:错误
stdout = read_all(child.stdout)   // 阻塞直到 EOF
stderr = read_all(child.stderr)   // 若子进程先把 stderr 写满 64KiB 并阻塞,永远等不到 stdout EOF
wait(child)

正确模式:

  1. 同时对 stdout、stderr 做非阻塞或独立线程 /async 读;
  2. 或使用 select/epoll/asyncio 多路复用;
  3. 语言自带的 communicate()(Python)、正确配置的 Promise 并行读(Node)已处理双管道并发,优先复用而非手写串行读。

推荐硬限制(在读循环内强制执行,而非等进程结束后再切片):

建议值 说明
合并输出上限 256 KiB~1 MiB(按模型上下文预算) 超过后停止投喂模型,可继续排空或直接杀进程
单行上限 8–32 KiB 防止无换行的超长行撑爆缓冲
行数上限 2_000~10_000 行 对测试日志类输出更直观
超限策略 truncate + 可选 SIGTERM 仅截断仍让进程跑完会浪费 CPU;默认超限即进入取消路径更稳妥
二进制检测 前 8 KiB 含 \0 则标记 binary 并停读 避免把 ELF / 图片当 UTF-8 回灌
编码 按 UTF-8 有损解码(errors=replace 保证 JSON 工具结果可序列化

截断后回灌建议带元数据,避免模型误以为输出完整:

{
  "exit_code": null,
  "signal": "SIGKILL",
  "stdout_truncated": true,
  "stdout_bytes_kept": 262144,
  "stdout_preview": "....",
  "stderr_preview": "...."
}

4. 与 cwd、环境变量、shell 选择的最小配合

进程组与管道策略不能单独构成安全边界,但下列参数常与生命周期层一起配:

建议 说明
shell 固定 bash -lc 或直接 exec argv 数组 禁止把未拆分的字符串交给可变 shell;argv 形式可减少一层引用解析
cwd 限制在 workspace 根之下并 realpath 校验 防止 cwd=../../../ 读到宿主机其它目录(仍需 OS 沙箱才扎实)
环境变量 显式 allowlist(PATHLANGHOME 指向临时目录) 避免把云密钥、AWS_*DATABASE_URL 默认继承进子进程
stdin 默认关闭(stdin=DEVNULL 防止子进程阻塞等输入;需管道喂入时设字节上限
文件描述符 close_fds=True(Python 默认在 POSIX 上接近此行为,需确认版本) 降低把 Worker 的监听 socket 泄漏给子进程的风险

5. 参考实现骨架(并发读 + 超时杀组)

下面用 Python asyncio 示意「读循环截断 + 超时杀组」的结构(生产需补审计日志与 cgroup):

import asyncio, os, signal

async def run_limited(cmd: list[str], timeout_s: float = 30.0, max_bytes: int = 256_000):
    proc = await asyncio.create_subprocess_exec(
        *cmd,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        start_new_session=True,
    )
    out_buf, err_buf = bytearray(), bytearray()
    truncated = False

    async def pump(stream, buf):
        nonlocal truncated
        while True:
            chunk = await stream.read(4096)
            if not chunk:
                break
            if len(buf) < max_bytes:
                need = max_bytes - len(buf)
                buf += chunk[:need]
                if len(chunk) > need:
                    truncated = True
            else:
                truncated = True

    try:
        await asyncio.wait_for(
            asyncio.gather(pump(proc.stdout, out_buf), pump(proc.stderr, err_buf)),
            timeout=timeout_s,
        )
        code = await asyncio.wait_for(proc.wait(), timeout=5)
    except asyncio.TimeoutError:
        truncated = True
        try:
            os.killpg(proc.pid, signal.SIGTERM)
            await asyncio.sleep(2)
            os.killpg(proc.pid, signal.SIGKILL)
        except ProcessLookupError:
            pass
        code = await proc.wait()
    return code, bytes(out_buf), bytes(err_buf), truncated

说明:create_subprocess_exec + start_new_session=True 在 asyncio 文档中与 Popen 同义字段对齐;若使用 create_subprocess_shell,攻击面与引号转义问题更大,仅在已对 command 做强校验时考虑。

风险与边界

脱离进程组的子孙:恶意或某些运行时(守护化、二次 setsid、向外部 systemd-run/nohup 委托)会使孙进程离开原 pgid。进程组杀不是万能容器;对不可信代码应叠加 cgroup v2 的 memory.max / cpu.max 与独立 user namespace,超限由内核回收。

SIGKILL 无法杀死的状态:处于不可中断 I/O(D state)或依赖故障 NFS 的进程可能暂时杀不掉。平台应把该 tool 调用标为失败并告警,而不是无限 wait

管道截断与语义正确性:提前 SIGTERM 或停读会导致命令「半完成」(例如包管理器写了一半缓存)。写工具必须配合幂等层与工作区回滚;只读调查类命令截断通常可接受。

输出中的密钥二次泄露:截断与脱敏是两件事。子进程若 echo $API_KEY 或打印 .env,预览字段仍可能含秘密。应在回灌前做高基模式脱敏(Bearer、AKIA、私钥 PEM 头等),且不要把完整环境变量表回显给模型。

kill(-pgid) 误用pgid 必须是本次 tool 的组 ID。对 pid=1 或错误的负数语义可能影响范围扩大;在容器内若未正确建组,行为与宿主机调试时不一致,需在目标运行环境做集成测试。

Windows / 无 POSIX 信号的平台:本文参数不直接适用。Windows 上应使用 Job Object 将子进程归组并 TerminateJobObject;跨平台 Agent 需抽象「进程树取消」接口,而不是假设 killpg 存在。

PTY 与管道差异:交互式 TUI、需要 TTY 的工具走伪终端时,缓冲与信号(前台进程组、SIGINT)语义不同。若 Agent 只做「批处理命令」,优先管道 + 无 TTY,行为更可测。

与 MCP / 编排超时的嵌套:工具墙钟 30s,外层 HTTP/MCP 请求 25s,会导致网关先断而子进程仍在跑。应保证 外层超时 ≥ 工具超时 + grace + 收尸,或在连接断开时仍由 Worker 异步完成杀组(需有 run 级后台收尾任务)。

参考来源

systems

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com