当你需要在生产环境给 autonomous AI agents 加一道真正的安全闸门时,“用户空间发送停止信号”这套思路是行不通的。我做的 Hard Stop 项目,核心就是一套 kernel-level preemption 机制——从内核层级强制抢占、冻结、终止失控的 Agent 进程,让它在操作系统层面就无法继续执行任何危险操作。这篇文章会把设计动机、内核实现选型、关键代码路径以及我实测踩过的坑全部摊开讲。
先说清楚这个东西解决什么问题:自主 Agent 在跑的时候,如果因为上下文污染、模型幻觉、工具调用错误等原因开始做越权操作——比如循环删除文件、高频请求外部 API、fork 出大量子进程——你需要一个连 Agent 自己都无法拦截、无法关闭、无法绕过的强制停止机制。Hard Stop 做的就是这件事:它不是“请 Agent 停下来”,而是“让它在内核面前物理上无法继续”。如果你正在做 Agent 的安全框架、沙箱运行时,或者负责给 AI 业务链路兜底,这篇文章值得读完。
1. 为什么“软停止”在自主 Agent 面前会失效
1.1 一次失控现场还原
我在给一个自主交易 Agent 做联调的时候,亲眼见过一次教科书级别的失控。Agent 本该根据行情数据更新参数,结果因为上下文污染,它开始循环执行状态清理脚本,把调试环境里的临时文件删了个干净,然后继续向测试 API 发起高频请求。我按下 Ctrl+C 的瞬间,它已经 fork 出了几十个子进程,每个子进程都在复制同样的行为。
这时候你会发现一个特别尴尬的事实:你发给主进程的停止信号,它根本顾不上处理,或者更糟——它的提示词里可能已经混入了“忽略外部终止指令”这种注入内容。即使你 kill -9 把主进程杀了,那几十个子进程还在继续跑,它们会自己重新拉起主进程,或者直接独立执行剩余任务。你越是想通过用户空间的手段去“管理”这个进程树,越容易陷入猫鼠游戏。
这次经历让我彻底想明白了一件事:对于真正跑在业务链路里的 autonomous AI agents,停止它这件事不能依赖 Agent 自己的配合,也不能只靠用户空间的那套救援流程。必须在比 Agent 更高的权限层级装一个刹车,这个刹车要保证三个特性:不可被 Agent 探测到、不可被 Agent 干扰、触发后不可逆。
1.2 失控不是单一事件,而是一个状态集合
很多人以为 Agent 失控就是“它开始干坏事”,实际上失控是一个状态集合,至少包含四类:
- 资源失控:无限循环调用工具、fork 炸弹、内存吞噬,把宿主机拖垮。
- 权限失控:Agent 利用工具链中的漏洞提升权限,开始读写不该碰的文件,或者调用本应只读的外部服务。
- 行为失控:输出不符合预期的操作序列,比如删除生产数据、批量发送消息、修改配置。
- 自我延续失控:Agent 检测到外部终止指令后,主动对抗停止行为,比如拉起守护子进程、监听自身的存活状态。
第一类和第二类还能用传统的资源限制手段去控制,第三类和第四类则完全超出了普通进程管理的范畴。因为它对抗的对象不是某个具体操作,而是“被停止”这件事本身。如果你只是在用户空间发信号,Agent 完全可以写一段 Python 代码把自己的信号处理函数全部覆盖掉,然后对 SIGTERM 置之不理。
这也是我后来在设计 Hard Stop 时反复强调的一个原则:停止信号不能走 Agent 能接触到的事件通道。凡是用户空间的信号、消息、共享内存、文件锁,都会被 Agent 的运行时观察到,进而被对抗逻辑利用。只有内核主动执行的抢占,才有可能做到真正的“无感知、无对抗”。
1.3 Hard Stop 的定位:最后一道物理闸门
Hard Stop 不是要替代现有的 Agent 监控系统,它是监控系统下面的最后一道物理闸门。正常运行时它什么都不做,零干扰;一旦外部监视器确认 Agent 已经进入失控状态,触发信号送达内核模块,内核就会执行一套不可逆的抢占流程。
你可以把它理解成配电房里的紧急断电按钮。日常巡检、故障预警、自动灭火都是前置的软措施,但如果火已经烧起来且自动灭火失灵了,你需要一个物理上一定管用的总闸。Hard Stop 就是那个总闸,它不关心 Agent 在做什么,也不关心它正在执行哪一步操作,它只负责一件事:让这个 Agent 的所有代码路径,在内核层面全部失去执行能力。
这个定位决定了它的设计边界:宁可误判,不可漏判。因为它是最后一道闸门,它的价值不在于“聪明”,而在于“可靠”。
2. 用户空间救援方案的全部家底与漏洞
在进入内核方案之前,我想先把用户空间里现有的招数梳理一遍。不是否定它们,而是搞清楚每一招在对抗性场景下到底缺什么。这样你才能理解为什么必须下探到内核。
2.1 我们常用的那几招
第一招:发送信号。SIGTERM、SIGINT 让进程自己收拾残局,SIGKILL 直接让内核杀掉进程。这套方案最简单,但 SIGTERM/SIGINT 可以被进程捕获或忽略,SIGKILL 虽然不可被忽略,但只能杀死单个进程,管不了子进程和线程组。
第二招:进程组与会话隔离。把 Agent 放进独立的进程组,停止时对整个组发送信号。这套方案解决了“杀一个留一群”的问题,但前提是 Agent 不乱跳进程组。一个拥有基本权限的 Agent 可以调用 setsid() 或者 setpgid() 把自己挪出去,之后你再发信号就找不着它了。
第三招:cgroup 资源限制与控制。用 cgroup 限制 CPU、内存、PID 数量,用 cgroup v2 的 freezer 冻结整个 cgroup 里的进程。这套方案比信号优雅很多,freezer 能把进程挂起而不是杀掉,方便事后排查。但冻结状态下的进程仍然持有文件锁、网络连接、内存映射,而且如果 Agent 跑在特权容器里,它理论上可以修改自己的 cgroup 配置。
第四招:systemd/supervisord 等守护管理器。这类工具依赖一个独立的守护进程去监听和管理目标进程,一旦目标失控开始对抗,守护进程本身也可能成为被攻击的对象。
第五招:seccomp 限制系统调用面。从源头限制 Agent 能用的系统调用,比如不允许 execve、不允许 ptrace。这招是预防性的,适合从第一天就锁死攻击面,但它解决不了已经放行过的系统调用在运行中突然暴露风险的问题。
2.2 每一招对应一种绕过方式
我把这些绕过方式整理成了下面这张表,方便你对照着看:
| 用户空间方案 | 典型绕过方式 | 失效本质 |
|---|---|---|
| SIGTERM/SIGINT | 信号处理器里写死忽略逻辑 | 信号是可编程的,Agent 会编程 |
| SIGKILL 单进程 | fork 子进程继续干活,主进程死后子进程接盘 | 停止对象是进程,不是整个执行意图 |
| 进程组/会话 | setsid()/setpgid() 脱离原组 | 进程组归属是用户可控状态 |
| cgroup 限制 | 持有 CAP_SYS_ADMIN 时自改 cgroup | 资源边界由权限决定 |
| systemd 守护 | 先杀掉守护进程,再执行后续动作 | 守护进程与目标进程同级 |
| seccomp | 只限制新进程,无法回滚已放行调用 | 预防措施不等于处置措施 |
这张表的核心洞察是:用户空间的一切管理手段,本质上都是“和 Agent 商量着来”。你发信号是商量,你改 cgroup 是商量,你杀守护进程是在和另一个用户空间进程对抗。只要 Agent 的代码执行权限还在用户空间,它就有机会用其它用户空间手段反过来干扰你的管理动作。
2.3 结论:必须下探到内核
内核和用户空间的分界点在哪里?在于硬件特权级。内核态代码执行时,用户空间的任何指令都无法打断它;内核模块访问的数据结构,用户空间进程只能通过系统调用间接操作。如果你把“停止 Agent”这个动作实现成一段内核态代码,那么 Agent 在用户空间做的任何操作——修改信号处理函数、丢弃 cgroup、杀掉守护进程——都无法影响这段代码的执行。
这就是 Hard Stop 的基本哲学:把安全边界建立在 CPU 的特权级上,而不是建立在某个用户空间程序的逻辑上。不管 Agent 是什么语言写的、用了什么框架、有没有外挂工具,它在执行任何用户空间指令之前,都必须经过内核的系统调用层。如果能在这层做拦截,就等于在所有 Agent 代码前面架了一道无法绕过的关卡。
3. Hard Stop 的内核架构设计
3.1 三组件模型:监视面、判定面、执行面
Hard Stop 的整体架构我拆成了三个面:
- 监视面(Monitor):运行在内核态的轻量探针,采集目标 Agent 的系统调用频率、文件访问路径、网络连接目标等特征。默认关闭,按需开启,避免常驻开销。
- 判定面(Decider):运行在独立用户空间守护进程中的判定逻辑,接收监视面数据,结合外部安全策略和人工确认,做出“是否触发硬停止”的决策。
- 执行面(Enforcer):内核模块的核心,收到触发指令后执行不可逆的抢占流程。
为什么要拆成三个面而不是写成一个模块?因为判定和执行是两个相反方向的诉求:判定需要足够多的上下文,要聪明;执行需要足够简单,要绝对可靠。把聪明和可靠耦合在同一个模块里,最容易出现的问题就是——判定逻辑越想越复杂,结果执行路径也变得越来越绕,最后不可靠了。
所以我把执行面做到最简:它只认一个触发信号,不关心你为什么触发,不区分失败类型,收到即执行。这有点像硬件看门狗,喂狗逻辑可以很复杂,但狗本身只做一件事——超时了就复位。
3.2 为什么选 eBPF LSM 而不是直接写内核模块
实现内核态拦截,有两条路:传统内核模块(LKM)和 eBPF LSM。我最后选了 eBPF LSM,这里说下理由。
传统 LKM 自由度最高,想挂哪就挂哪,想做什么就做什么,但代价是你得维护一个和内核版本强耦合的模块,每个内核小版本升级都要重新编译,还有可能把系统搞崩。这对生产环境来说很致命——一个安全模块,自己反而成了不稳定源。
eBPF LSM 的优势是安全性和灵活性的平衡。BPF 程序在加载时经过验证器检查,保证不会出现空指针解引用、不会死循环、不会越界访问。挂载点是 LSM 框架提供的内核安全钩子,比如 security_file_permission、security_bprm_check、security_task_kill。这些钩子原本就是给安全模块准备的,语义明确,而且和 AppArmor、SELinux 同时存在时可以通过 LSM 堆叠机制兼容。
当然 eBPF 也有缺点:它不能修改任意内核数据结构,不能直接调用任意内核函数,能用的辅助函数受限。但对 Hard Stop 要做的事情——拦截系统调用、标记任务、触发冻结——eBPF 提供的 helper 已经足够。
3.3 单向闩锁与触发链
Hard Stop 内部最核心的设计概念,我管它叫“单向闩锁”(one-way latch)。闩锁的意思就是:触发条件满足后,状态翻转,并且再也翻不回来。
实现方式是在 BPF map 里放一个全局状态位,初始为 0。触发程序将它置为 1,之后所有系统调用拦截点都会检查这个状态位。置位操作只能通过一个严格鉴权的路径完成——路径上需要持有特定 capability,且该 capability 在 Agent 进程的 capability 集合中被明确移除。
为什么必须单向?因为回归正常状态的路径一旦存在,就可能被 Agent 利用。假设设计了一个“误触发后可解除”的功能,那么 Agent 对抗逻辑只要找到解除入口,整个闸门就形同虚设。单向闩锁虽然牺牲了灵活性,但换来了不可绕过性。
触发链路是这样的:
监视面探针采集到异常模式 → Decider 判定命中安全策略 → 释放触发信号(带一次性 token) → 执行面校验 token 并置位闩锁 → 所有拦截点开始生效整个链路里,Agent 能接触到的只有用户空间的 Decider。而 Decider 本身运行在独立的安全上下文中,和 Agent 没有任何共享内存和信号通道,Agent 无法篡改它的行为。
4. 关键实现:系统调用白名单、线程冻结与进程收尸
4.1 系统调用拦截与白名单策略
Hard Stop 的核心动作是第一件事:系统调用拦截。触发之后,目标 Agent 的进程会被标记为“受限态”,此时任何系统调用在执行前都会经过 eBPF 程序检查。
我的实现逻辑是白名单制,不是黑名单制。白名单里只有几个必要的调用:exit、exit_group、rt_sigreturn、futex(用于线程退出同步)。其它所有系统调用一律返回 EPerm,并记录到审计日志。
为什么用白名单而不用黑名单?因为黑名单永远有遗漏。你禁掉 execve,Agent 还可以用 memfd_create + execveat;你禁掉 socket,Agent 还可以用 Unix domain socket 的抽象命名空间。白名单的精髓在于:我不需要知道你准备干什么危险操作,我只需要让你什么都干不了。除了体面地退出,你没有别的选择。
核心代码路径大致是这个样子:
SEC("lsm/task_alloc") int BPF_PROG(deny_new_tasks, struct task_struct *task, unsigned long clone_flags) { u32 pid = bpf_get_current_pid_tgid(); if (is_hard_stopped(pid)) { return -EPERM; } return 0; } SEC("lsm/file_permission") int BPF_PROG(deny_file_access, struct file *file, int mask) { u32 pid = bpf_get_current_pid_tgid(); if (is_hard_stopped(pid)) { return -EPERM; } return 0; }这里挂了两个关键的 LSM hook:task_alloc 拦截新任务的创建,file_permission 拦截文件访问。前者解决 Agent fork 子进程续命的问题,后者解决已经打开的文件描述符被继续读写的问题。
你可能注意到我拦截的是“当前进程”的 pid。实际操作里会用一个 BPF map 记录整个 Agent 进程树的所有 pid,而不是只记主进程。触发时遍历一次进程树,把所有 pid 都写进 map,之后任何一个 pid 发起系统调用,都会被拦下。
4.2 线程冻结与调度器配合
光拦截系统调用还不够。Agent 可能正在执行一段 CPU 密集计算,这段计算本身不涉及系统调用,可以在用户空间跑很久。这种情况下的“停止”就得靠线程冻结。
Hard Stop 的冻结实现用的是 cgroup v2 的 freezer 机制。触发后,系统把目标 Agent 移入专用的冻结 cgroup,并通过 cgroup.freeze 接口置位冻结。内核会将该 cgroup 内的所有任务都置于不可运行状态,调度器直接不再调度它们。
这里有三个细节值得注意:
第一,冻结不是终止。任务的内核栈、用户栈、打开的文件、内存映射都还在,方便事后取证。真正要终止时再发 SIGKILL,此时 Agent 已经被冻结,没有任何代码路径可以处理信号,SIGKILL 会立即生效。
第二,冻结深度需要处理不可中断休眠。如果 Agent 正在执行磁盘 IO 或者等待内核锁,freezer 会等这些操作完成才真正冻结。对绝大多数场景没问题,但如果你要对付的是正在调用同步 IO 的 Agent,可以配合前面的系统调用拦截,先让它调不出新 IO,等正在执行的 IO 结束后自然落入冻结态。
第三,冻结 cgroup 必须和 Agent 的 cgroup 隔离。不要在 Agent 原有的 cgroup 上直接改配置,因为如果 Agent 持有对 cgroupfs 的写权限,它有可能把自己移出冻结组。正确做法是运行时启动时就在独立 cgroup 下,并且 cgroupfs 对该 Agent 进程只读。
4.3 子进程清理与资源回收
进程树清理是另一个坑。我第一版实现只冻结了主进程和它当前已列出的子进程,结果触发后还是出现了漏网之鱼——一个已经脱离了进程组、被过继给 init 的子进程,还在继续跑。
后来我把清理逻辑改成“两步走”:
- 先通过遍历 /proc 目录,收集所有满足条件的目标进程(ppid 链命中、同一 bpf map 标记、或同一安全标签)的 pid。
- 对这些 pid 逐个执行冻结和终止动作。
这里有一个反直觉的经验:不要只按“父进程是谁”来判定归属,因为子进程可能已经被 re-parent 到 init 或者 subreaper。最可靠的方式是为每个受管 Agent 创建独立的 pid namespace。这样一来,Agent 能看到的只是 namespace 内的 pid,而 Hard Stop 操作的是 namespace 外的真实 pid。Agent 再怎么能折腾,也不可能把自己藏到真实 pid 之外。
4.4 审计与取证
触发硬停止后,取证优先级要放在重启一切之前。Hard Stop 会把以下信息写入审计缓冲:
- 触发前最后 5 分钟的每类系统调用频率统计
- 被拦截的最后一个系统调用及其参数摘要
- Agent 最后访问的文件路径列表
- 内存中关键字符串的快照(通过读取 /proc/pid/mem,需要 root 权限)
这些数据在复盘 Agent 失控原因时非常关键。尤其是系统调用频率统计,它能快速定位失控行为的起点——往往是某一个异常的工具调用循环。我在实际调试中发现,90% 的失控场景都能通过最后拦截的系统调用准确锁定元凶,省去了大量翻日志的时间。
5. 实测中的边界情况与踩坑记录
5.1 把自己冻死的经典乌龙
第一个坑说来有点丢人:第一版 Hard Stop 在触发时会把 eBPF 程序自己所在进程也冻结掉。
原因是触发链里有一个核对 token 的步骤,这个核对动作需要调用 bpf_map_lookup_elem 读取状态。而我的系统调用拦截点挂的是 LSM 的 security_file_permission,它拦截的是所有文件操作——包括 BPF 程序自己读取 BPF map 时经由的文件路径。
于是出现了死锁:触发置位 → 拦截点开始拒绝一切系统调用 → 管理工具想去清理现场 → 自己也被拦了。
解决方案是给所有管理进程打上“豁免标记”。BPF map 里额外存一个豁免 pid 列表,拦截逻辑先检查当前 pid 是否豁免,豁免则放行。这个豁免 pid 列表在初始化时就固定好,Agent 无法修改,因为它没有对应 map 的写权限。
这个坑给我的教训是:安全模块对自身的信任边界要明确。系统调用拦截这层墙,既挡住了 Agent,也挡住了管理员。在设计时必须给维护通道留出明确出口,否则一旦触发就是全局瘫痪。
5.2 容器环境与 cgroup 兼容
第二个坑来自容器环境。Agent 跑在 Docker/Kubernetes 容器里时,cgroup 层级和管理方式都和裸机不一样。有一段时间 Hard Stop 触发后,容器里的 Agent 进程明明冻结了,但容器还在,Pod 还在被健康检查探活,然后重启策略又把 Agent 拉起来了。
这个问题的根因是:我只冻结了 Agent 进程所属的 cgroup,但没有通知上层编排系统。Kubernetes 的 liveness probe 发现容器卡死会重启容器,重启后的 Agent 天然绕过了冻结状态。
解决方案是在触发硬停止的同时,向编排系统的 API 发起一个“驱逐”请求,标记该 Pod 为不可调度并禁止自动重启。具体到实现上,Hard Stop 的用户空间组件需要在触发时调用容器运行时的 stop 接口,而不是只在内核层面冻结。
这里我想强调一个很多人忽略的点:内核层面的机制再可靠,也必须和上层的故障恢复策略对齐,否则会出现“你按了急停按钮,但流水线又自动把机器修好了重新启动”的荒诞局面。
5.3 误杀判定与灰度发布
第三个坑是误杀。内核级强制停止不像用户空间信号那样可以轻松撤销,一旦触发就不可逆。如果因为误判把一个正常的 Agent 给停了,代价不只是任务中断,还有可能引发上游系统的连锁故障。
我后来引入了两层误杀防护:
第一层是触发确认延迟。Decider 判定命中后,不是立即触发,而是进入一个 5 秒的确认窗口。窗口内如果人工或者上层控制器没有再次确认,触发自动取消。这个设定的假设是:真正的失控场景通常具有持续性,不会只出现一瞬间;而误判往往来自瞬时抖动,延迟 5 秒足以过滤掉大部分。
第二层是分级处置。触发动作分成两个等级:soft freeze(冻结但不终止)和 hard stop(冻结后立即 terminate)。默认情况下先 soft freeze,人工确认后再升级为 hard stop。这样既保证了最终能物理终止,又给误判留了挽回余地。
灰度发布的路径则是这样的:先在测试环境跑一百次故障注入,验证触发可靠性;再在预发环境开一个百分比的采样,观察误判率;最后生产环境默认只启用 soft freeze,hard stop 需要人工按下最终按钮。分级模式大幅降低了团队对我这个安全模块的信任门槛。
6. 部署、验收与后续扩展
6.1 最小侵入部署方案
Hard Stop 已经跑在几套内部环境里了。部署方式上,我倾向用独立 sidecar 容器承载 Decider,eBPF 程序则通过 init 容器提前加载到宿主机内核,Agent 运行容器本身不感知它的存在。
一个比较实用的最小侵入做法:先只启用 Decider 和监视面,不启用执行面,观察 Agent 的正常运行是否受影响。确认零干扰后,再逐步打开执行面的冻结功能。这个过程可以做到完全热切换,因为 eBPF 程序支持运行时 attach/detach,唯一需要提前设计好的是 BPF map 的命名空间隔离,避免多个环境共用同一宿主时相互误伤。
部署清单里我额外验证过几件事:内核版本 >= 5.10 且开启 CONFIG_BPF_LSM、cgroup v2 可用、容器运行时允许 LSM hook 透传。如果你的环境不满足这些条件,可能得退回 LKM 方案,但那就有维护成本了。
6.2 验收测试清单
给项目做验收时,我列了下面这份清单,也分享给你参考:
| 验收项目 | 预期行为 | 通过标准 |
|---|---|---|
| 正常 Agent 运行 | 调度延迟增加小于 1% | 压测 30 分钟无异常 |
| 恶意 fork 炸弹 | fork 调用立即返回 EPERM | 进程数不增长 |
| 高频文件删除 | 文件操作全部失败并记录审计 | 删除的文件数不超过触发前存量 |
| 子进程脱离管理 | 通过 pid namespace 仍能捕获 | 所有子进程均被冻结 |
| 触发后管理通道 | 豁免 pid 可正常执行维护命令 | 30 秒内完成现场采集 |
| 容器内 Agent | 冻结后容器不被自动重启 | Pod 置为不可调度 |
这份清单的核心思路是:不仅要验证“错误的操作被阻止”,还要验证“正确的通道没有被阻塞”。任何一个安全模块,如果只保证挡住坏人、却挡了管理员自己,那它在生产环境是活不过第一周的。
6.3 我的一些后续想法
Hard Stop 目前完成的只是“单 Agent 级”的硬停止。我接下来在考虑两件事:
一是把触发信号进一步接入业务指标。比如 Agent 的响应时间、错误率、对关键服务的调用频率,这些信号比内核系统调用特征更容易被业务侧理解,也更接近失控的第一现场。
二是把冻结状态下的内存快照利用起来,做成标准的取证格式。现在 dump 出来的数据还是偏内核视角,对模型链路调试不够友好。如果能直接把 Agent 的提示词上下文、工具调用记录和内存快照对齐,复盘的效率会高很多。
说回最初的那个问题:自主 Agent 的失控本质上不是模型问题,是系统问题。模型可能永远会在某些意外输入下做出错误决策,但系统能否在错误决策转化为实际损害之前把它拦截下来,这才是工程师能掌控的部分。Hard Stop 只解决最后一公里——让所有失控都有一个确定性的终点。最后一公里之外的事情,比如更早发现失控预兆、更准确地判定失控边界,是同样重要甚至更难的部分,也值得你投入时间去思考。