1. 先把话说透:沙箱不是保险箱
1.1 一听到“沙箱”两个字,先别急着放心
最近在帮某团队调一个基于 Agent 的自动化工作流,排障的时候几乎每一轮讨论都会出现同一句话:“这段代码放在沙箱里跑,安全问题不大。”一开始我没太在意,后来发现团队里每个人对“沙箱”的理解都不一样。有的以为是临时目录,有的以为是函数级权限控制,有的干脆觉得只要是容器就等于沙箱。
先说结论:沙箱的核心目标从来不是“隔离”,而是“断后路”。它要解决的事情,是把一段不可信代码或一个不可信决策的执行后果,限制在一个可回收、可观测、可回滚的范围内。隔离只是手段,可控才是目的。你可以在没有沙箱的情况下做隔离,也可以在没有完美隔离的情况下做沙箱——很多生产环境里的 Agent 沙箱,其实做的都是后者。
我在实际项目里见过的最典型误解,是觉得“代码进了沙箱,就不会碰外面的东西”。这个想法很危险。大多数 Agent 场景下的沙箱,防的是误操作和失控,防不了恶意攻击。如果你判断错了边界,把保护预期拉得太高,后面出了问题反而更难排查。
1.2 市面上三种常被误叫“沙箱”的东西
排障时我习惯先问对方:你说的沙箱,具体是哪种?通常得到的答案可以归成三类,但它们都不等于真正的沙箱。
第一类是“临时目录 + 子进程”。很多人写脚本时习惯把中间文件放/tmp/workdir,然后用subprocess发到另一个进程去执行,就觉得安全了。这不是沙箱,这只是把文件挪了个位置。子进程的权限跟你主进程完全一样,它在临时目录里只是“看起来隔离”,一旦代码内部有路径拼接漏洞,照样能访问用户主目录、系统配置文件,甚至删库。
第二类是“语言层面的权限拦截”。比如在 Python 里通过装饰器限制某个函数只能读白名单文件,或者在 JS 里用vm模块做上下文隔离。这种“逻辑上的沙箱”能在一定程度上防住粗心代码,但对绕过很不设防。语言层面拦截不了系统调用,拦截不了外部网络请求,也拦不住fork之后孙子进程的行为。作为第一道防线可以,作为唯一防线绝对不行。
第三类是云平台上的“函数沙箱”概念。很多云函数、边缘计算、Web IDE 都宣称自己提供一个沙箱环境,但实际上大量实现是“权限控制 + 资源配额”,底层是共享内核的容器。它确实限制了 CPU、内存、时间,也做了网络隔离,但如果你自己没能力验证,最好默认它“防误操作有余,防攻击不足”。
这三类东西都有一个共同点:它们在大部分正常场景下够用,但一到 Agent 这种会把代码生成当日常操作的系统里,就明显不够了。因为 Agent 产生错误行为的概率,远高于普通程序员写出的代码——这不是它笨,而是它的探索路径是概率性的,天然会产生大量“看起来合理但后果离谱”的尝试。
2. Agent 为什么非要进沙箱?
2.1 失控成本,成倍高于传统脚本
传统脚本是确定性的:你写什么,它跑什么,出了问题基本能在代码里一眼定位。Agent 不一样,LLM 会在执行过程中自主决定下一步调哪个工具、传什么参数、按什么顺序执行。也就是说,你没法在发布前把所有路径“读一遍”。
我团队的项目 X 里,一个 Agent 需要完成“读取用户上传的表格 → 清洗数据 → 生成图表 → 按模板输出报告”这条链路。看起来简单对吧?但中间任何一个环节都可能偏掉:它可能为了“探索数据分布”去加载一个外部库,可能为了“验证结果”写一个临时脚本再执行,甚至可能为了完成一个模棱两可的任务,递归调用自己三次。这些行为在单体函数里不会出现,但一旦框架把所有能力暴露给 LLM,它就变成了日常。
失控成本上升的根源在“自由度”。过去你只需要约束一个程序员写的代码,现在你需要约束一个模型生成的所有代码。前者出错是低频事件,后者出错是系统设计必须默认的高频事件。沙箱不是用来防止“Agent 变成破坏者”的,而是用来防止“Agent 只是犯了个正常错误,却把整个宿主环境拖下水”。
2.2 四类必须默认防住的事故
以我的经验,Agent 沙箱至少要覆盖四类事故,下面每一项都在真实项目里踩到过。
第一类:读取敏感文件。Agent 在处理用户任务时,经常需要遍历目录、搜索文件名、读取配置。在宿主环境里,它可能无意间读到环境变量里的密钥、用户主目录下的 SSH 私钥、数据库连接串。这类问题看起来不像“执行了恶意代码”,但危害一点也不小——一旦日志被外部获取,或者 Agent 的中间结果被上传,敏感信息就泄漏了。
第二类:对外发起网络请求。不是每个 Agent 都需要自由外网的。很多场景下,它只需要访问一两个白名单 API。一旦放开了外网,代码就可能因为 LLM 的一个“顺手尝试”,去请求未知域名、执行扫描、甚至把结果回调到一个你自己的服务器。
第三类:修改宿主系统状态。包括写文件到系统目录、改/etc配置、安装全局依赖、修crontab等等。对于 Agent 来说,它可能只是“想把依赖装到全局方便下次复用”,但对宿主系统来说这是一个灾难。沙箱要保证:无论 Agent 怎么折腾,宿主机的文件系统都不受可持久化影响。
第四类:无限循环和自我复制。LLM 生成代码时经常写出“递归调用某个函数”“根据输出再次调用同一个 Agent”的逻辑。放在宿主环境里,轻则耗尽 CPU,重则产生大量子进程,把整个机器拖到不可响应。沙箱里必须有硬性的资源上限和超时机制,不能指望 Agent 自己“感到不对劲就停下来”。
所以你现在应该能理解,为什么“临时目录 + 子进程”这种方案在 Agent 场景里行不通了——它防住了文件误写,但防不住网络请求、进程爆炸和敏感文件读取。真正可用的沙箱,必须在这四条线上同时设卡。
3. 沙箱的四个技术层级,选型前必须搞明白
3.1 进程级沙箱:最轻,但最不可靠
进程级沙箱是最常见的轻量方案,它的本质是:在同一个操作系统里,用一组资源限制和权限限制去约束子进程。典型手段包括 Linux 的setrlimit、ulimit、prlimit,以及seccomp系统调用过滤,还有namespaces(虽然 namespaces 算更底层,但多年间它经常和进程控制混着用)。
这类方案的好处是启动快、资源开销小,几乎不需要额外的镜像和运行时。你可以直接在代码里调用subprocess时带上preexec_fn降权,再用resource模块限制 CPU 时间和内存。
但它的天花板也很明显:进程级沙箱跟宿主机共享内核。如果用户利用内核漏洞,或者利用setns等机制尝试重新进入另一个命名空间,隔离就失效了。对于非安全专业的开发者,自制一套进程级沙箱很容易漏配参数,比如只限制了进程数、没限制文件句柄,或者只限制内存、没限制网络。我见过一个项目,沙箱里跑 Python 脚本,没有任何网络限制,脚本内通过socket就能直接访问内网地址,而这个“沙箱”还写在排障文档里,误导性很强。
结论很直接:进程级沙箱适合在你完全信任脚本来源、只求“防手滑”的场景下用。对于 Agent 这种不可信执行体,它只能作为补充层,不能当主体。
3.2 容器级沙箱:当前性价比最高的主线
容器沙箱是目前 Agent 工具链里用的最多的方案。它的核心是 Linux 内核的namespaces+cgroups组合。namespaces提供了隔离视图:进程看到的 PID、网络栈、挂载点、主机名都是自己命名空间里的“虚拟版本”,cgroups则负责资源限制:CPU、内存、PID 数量、IO 都能设上限。
用容器跑 Agent 代码,最大的收益是“环境可还原”。我可以基于一个干净镜像,挂载业务需要的目录,生成一个新容器,Agent 在里面随便折腾,完事之后整个容器删掉,下次再从头创建。整个过程中宿主机唯一接触的是挂载出来的那几个目录,而且这些目录还可以做成只读。
安全性方面,容器默认是有风险的,因为容器里的进程如果以 root 运行,并且宿主机内核有漏洞,理论上还是可以提权到宿主机。这就是为什么实际落地时我会把“用户命名空间”打开,让容器内的 root 映射成宿主机上的非特权用户,再配合只读根文件系统和能力集裁剪。这样即使容器内代码拿到了所有权限,它在宿主机上也只是一个普通用户。
容器级沙箱适合绝大多数 Agent 场景,运维成本相对可控,配置成熟,文档丰富。接下来第 4 节我会给出一套可以直接用的最小配置。
3.3 虚拟机级沙箱:安全上限高,成本也高
虚拟机沙箱是隔离强度最高的方案。虚拟机里跑的是一个完整的内核,虚拟硬件的解码和中断由 hypervisor 管理,guest 内核漏洞没法直接跨越虚拟化边界。对 Agent 来说,这就是“一个独立的电脑”,里面怎么折腾都不影响外面的机器。
代价也很直观:启动慢,资源占用大。一个最小化的轻量虚拟机也要几百兆内存,启动时间通常按秒算。如果 Agent 每天要执行几千次短任务,虚拟机的调度开销会成为一个明显的瓶颈。另外,常规虚拟机管理组件普遍偏重,部署起来比容器方案复杂不少。
我的建议是,只有当你的 Agent 可能面对强对抗输入(比如解析不可信的文档、运行未知来源的二进制、处理可以联网的第三方库)时,才把虚拟机级方案纳入考虑。日常跑日常工作流,用容器级就已经够了,不用为了配置里的“安全感”盲目升级。
3.4 远端沙箱和浏览器侧沙箱:你其实天天在用
还有一个容易被忽略的层级:把沙箱放在平台侧,或者放在浏览器内部实现。前者就是你在各种云函数、网页 IDE 里看到的“托管执行环境”,它不一定给你真正的内核级隔离,但它胜在统一管理、自动扩缩容。后者则利用浏览器渲染进程本身的安全边界,典型的例子就是浏览器标签页崩溃不影响整个浏览器。对 Agent 来说,这种模式更像是“服务端执行”的延伸,你只需要定义好接口协议,具体执行由平台决定。它的好处是省心,坏处是你没法控制底层细节,出现安全级别的需求时,你可能还是得回到自己搭的容器方案。
4. 一套可直接落地的 Agent 沙箱方案
4.1 选型思路:别追求“完美隔离”,追求“可恢复”
我在实际项目里给 Agent 配沙箱,一直遵循一个原则:不追求绝对安全,追求每一条“事故链”都有至少两道拦截。一个单纯 docker run 的容器如果直接挂载-v $HOME:/host,那它跟裸奔没什么区别,因为 Agent 可以任意读写宿主文件。所以我的方案分成五条线,每条线负责堵住一类事故。
4.2 五条防线逐条搭建
第一条防线:镜像与文件系统。基础镜像尽量用精简版,不装编译工具链,不装包管理器缓存。启动容器时,根文件系统挂载为只读,只把业务需要的一个工作目录做成可写卷。这个卷也要有大小上限(比如 tmpfs 或配额 vol),防止 Agent 写满磁盘。
第二条防线:进程与用户权限。容器内关闭高危能力,比如CAP_SYS_ADMIN、CAP_NET_ADMIN、CAP_SYS_PTRACE。容器内使用专用运行用户,不要用 root。加上用户命名空间,将容器内 root 映射为宿主机普通用户。
第三条防线:网络限制。默认使用--network none,从物理上隔离外网。如果需要访问 API,就用一个极少数的白名单代理,而不是直接开放网卡。很多 Agent 场景其实连白名单都不需要,纯粹在本机处理文件就够了,此时关闭网络反而是最省心的。
第四条防线:资源与超时。给容器设置 CPU 配额、内存上限、最大 PID 数。注意这里的“超时”不是脚本级的time.sleep,而是外部用timeout命令控制整个容器的生命周期。一旦超时,直接杀掉容器并清理。
第五条防线:审计与日志。容器的标准输出、错误输出、退出码都要持久化,同时记录容器启动时的镜像 ID、启动参数、时间戳。这一步是“可恢复”的基石,没有日志,即使你回收了容器,也不知道 Agent 到底干了什么。
4.3 具体命令与配置示例
实际项目里,我会用docker run加一组参数来实现上述防线。这里给出一套最小可用的命令:
docker run --rm \ --name agent-sandbox \ --network none \ --read-only \ --tmpfs /tmp:rw,noexec,nosuid,size=256m \ -v /data/agent_workspace:/workspace:rw \ -v /data/agent_cache:/cache:rw \ -e WORKSPACE=/workspace \ -e CACHE_DIR=/cache \ --user 10001:10001 \ --cap-drop ALL \ --security-opt no-new-privileges \ --pids-limit 256 \ --memory 512m \ --memory-swap 512m \ --cpus 0.5 \ agent-sandbox-img \ timeout --foreground --kill-after=5s 60s /bin/run_agent.sh逐条说一下:--read-only把根文件系统变成只读,--tmpfs创建了一个有容量上限的临时目录,Agent 可以在这里写临时文件,但写不进系统目录。--user 10001:10001让容器内服务使用低权限用户,--cap-drop ALL把继承的 Linux 能力全部丢弃,--security-opt no-new-privileges防止进程获得更高权限。--pids-limit限制最大进程数,--memory限制了内容容量,--cpus限制 CPU 使用率上限。最后的timeout --foreground --kill-after=5s 60s是整个方案最关键的一环:它从外部强制执行超时,先发 TERM 信号,5 秒之内没有退出就直接 KILL。
需要注意的是,--rm会在容器退出后自动删除容器,日志却要留存下来。所以我会在run_agent.sh里统一把所有输出写到/logs目录,然后把这个目录挂载到宿主机持久化。或者,如果你不想靠脚本内部环境变量,也可以把日志放在/workspace下,反正业务结束后宿主机会同步归档该卷内容。
4.4 运行验证:别急着信配置,先做破坏性测试
配置完成后,我习惯先“故意”让 Agent 变坏,验证防线是否真的兜得住。测试案例一般包括:
- 在容器内尝试写
/etc/passwd,预期是read-only file system报错。 - 在容器内尝试
curl https://example.com,预期是网络不可达。 - 在容器内运行一个死循环挖空 CPU,预期是容器被 KILL 或者 CPU 使用率被压制在 0.5 核以内。
- 在容器内尝试
fork大量子进程,预期是触发pids-limit拒绝。 - 删除
/workspace下的文件,预期是宿主机对应目录也变化——但只有/workspace这一个目录受影响,别的一概碰不到。
每次测试都记录下来,形成一份“沙箱验证清单”。后续改配置、换镜像、升级内核,都要重跑一遍。这套验证看起来很基础,但它能帮你尽早发现配置漂移或工具链版本变化带来的安全缺口。
5. 沙箱成功运行的关键,在沙箱外面
5.1 超时不是“跑够了就停”,是“跑不够也得退”
我见过不少团队给 Agent 加了 timeout,但实现的特别敷衍:在函数内部调用asyncio.wait_for,或者给 HTTP 请求设置一个timeout=30。这种做法的问题是,只覆盖了代码执行里最浅的那一层。如果 Agent 调用了一个遭阻塞的本地命令,或者启动了多个子进程等待网络响应,这些子进程根本不知道上一级的超时已经触发。
所以超时一定要放在沙箱边界之外。也就是我们上面命令里做的:用一个timeout进程包裹容器入口点,同时配合容器生命周期管理。如果你用的是容器编排平台,类似功能在 Job 级别也能配置activeDeadlineSeconds。最关键的是,超时触发后要做两件事:杀掉容器,保存退出时的日志。因为 Agent 常常在超时前已经在磁盘上留下中间产物,这些产物对排查“为什么超时”很有价值。
还有一个很阴的坑:timeout命令默认只发送 TERM 信号,如果容器内进程不响应,容器就会一直占着资源。所以务必把--kill-after参数加上,给它一个强制的 KILL 兜底。
5.2 审计要细到什么程度:连“没发生的事”也要记录
审计日志不只要记录“Agent 做了什么”,还要记录“它在沙箱里拥有什么条件”。比如:容器启动时挂载了哪些目录、环境变量有哪些、网络策略是什么、镜像版本是什么。这样事后才能回答“为什么沙箱里能访问到数据库”这类问题——很可能不是 Agent 的行为异常,而是你在启动参数里给漏了。
我在项目里会额外记录一条“决策轨迹”:每一次 Agent 调用工具的前后,将输入摘要、输出摘要、耗时、退出码写到审计库。这样即使日志文件丢失,也能从轨迹层做重建。最开始时我觉得这是过度设计,直到有一次 Agent 在半夜自动跑任务时把错误命令执行了两三百次,如果没有轨迹记录,我根本猜不到它是怎么一步步走到那个状态的。
5.3 可观测性:为什么 Agent 在沙箱里“看起来没干活”
沙箱把 Agent 的破坏范围控制住了,但也衍生了一个副作用:你很难直观看到它在做什么。因为容器内进程跟宿主机隔绝,top和ps上只能看到容器整体的资源数字,看不到 Agent 内部各工具调用的细粒度情况。
解决方案是在沙箱内引入可观测探针。最简单的方式是在 Agent 的运行框架里增加一个“命令逐步上报机制”,每执行一条命令就把执行时间、返回码、输出片段写入 Redis 或本地 SQLite。这不会牺牲沙箱的安全性,因为这些上报目标是白名单。很多团队把这一步省了,结果沙箱一出问题就是黑盒:容器没了,日志没带走,Agent 到底干了什么全靠猜。
我的经验是,任何沙箱方案都要回答三个问题:如果 Agent 卡住了,你能看到它在哪一步卡住的吗?如果 Agent 出错,你能看到它错误之前的一百个操作吗?如果 Agent 产生了不可预期的结果,你能回放到启动时的完整上下文吗?只要有一个问题答不上来,沙箱的闭环就是断的。
6. 常见问题速查与避坑实录
6.1 “沙箱里一切正常,宿主上文件全乱”是怎么回事
这是我接到过的最多咨询。检查下来,绝大多数原因都是挂载目录范围划得太大。有人图省事,直接挂载/data甚至/home进沙箱,还安慰自己“容器只读就不会有问题”。但容器内的只读是针对根文件系统,挂载进来的数据卷是可以被写坏的。只要给容器加了写权限,它就等于你亲手把家目录钥匙递了进去。
正确做法是:把 Agent 需要访问的最小目录集列出来,逐个挂载。不需要访问的,一律不挂。如果你的 Agent 需要访问目录结构防火墙式的覆盖范围,那就考虑通过一个文件访问网关 API 来做,而不是直接把宿主目录全量暴露。
6.2 容器内时间不同步,缓存签名一直报错
这个问题容易被忽略:容器默认独立于宿主时钟,但绝大多数容器实现会与宿主共享时钟。在 Agent 项目里,比较常见的是沙箱内生成的文件时间戳,和宿主机归档文件时间戳不一致,导致“构建缓存失效”或者“签名验证失败”。
解决方式有两种:一是启动容器时挂载/etc/localtime时区文件;二是统一约定所有时间戳使用 UTC 写入,不要在业务代码里依赖本地时区。实测下来,第二种更干净,因为它不与宿主环境绑定,日志也更好排序。
6.3 白名单网络开了,但 Agent 还是请求失败
很多团队在--network none方案下,发现业务需要访问一个内部服务,于是改成自定义 bridge 网络,并只允许特定端口。改完之后,容器确实能访问那个端口了,但 Agent 里其它本该被拦截的请求竟然也通了——排查下来,是因为填--network参数时图省事,直接挂到宿主网络里了。
自定义 bridge 的“白名单”感是假象。它只隔离了广播域,并没有让你天然拥有端口级控制能力。如果你想精确限制 Agent 只能访问少数几个地址,正确做法是添加一个代理层:容器网络不通外网,只连这个代理,代理再做目标白名单。这样才能真正实现“渲染进程崩了不影响浏览器主进程”式的可控性。
6.4 快速排查表
| 症状 | 先查这个 | 大概率原因 |
|---|---|---|
| 容器启动失败,报权限错误 | 用户命名空间与--user配置 | 容器内用户 ID 与挂载卷权限不匹配 |
Agent 能写/workspace,但宿主机看不到变化 | 挂载类型 | 用 tmpfs 而不是 bind mount |
| 容器被杀但日志丢失 | docker run --rm的清理机制 | 没有把日志先导出到持久化目录 |
| CPU 超限后容器 OOMKilled | 内存限制太紧 | 只设了--memory,没给合理 headroom |
沙箱里apt install失败 | 网络策略 | 你的容器根本没网,这是正确行为 |
6.5 内核逃逸和“沙箱膜法”的幻觉
最后必须泼一盆冷水:没有任何一个软性沙箱能在内核漏洞面前许诺绝对安全。容器、进程、namespaces 在实现上都有历史性的逃逸案例。即便你配好了上面所有防线,也只能说“常规事故被拦住”,不能说“绝对安全”。
所以,一定要把“沙箱安全”和“业务安全”分开看。沙箱安全回答的是“Agent 会不会不小心把宿主搞崩”;业务安全回答的是“Agent 会不会做出错误决策导致用户数据泄漏”。后者靠白名单、人工审批、提示词约束、策略引擎来实现,不在沙箱讨论范围内。我的项目 X 里,这两套机制是分开搭建的:沙箱管执行环境,业务策略管决策边界。别把两者混为一谈,这是最省心的认知框架。
7. 最后说点实操里的体会
我踩过几次坑之后,最大的心得其实是:沙箱不是安全人员丢给你的一套工具,而是整个 Agent 系统设计的一部分。配置一个容器很容易,难的是想清楚每个边界设在哪里、每份日志如何流转、每次失败之后如何复位。个人建议从一个小范围项目开始,先只允许 Agent 访问一个目录、关闭网络、限定 300MB 内存,跑通一个端到端任务之后,再按需放开。放开一项能力之前,先写清楚“这项能力被滥用会怎么样、日志够不够复盘”。这样一步步扩,比一开始就企图一步到位要稳得多。
另一个小技巧:每次 Agent 任务结束前,把容器内/workspace下所有文件的哈希值记录下来,和启动前的快照对比。如果发现多了文件,审一下内容再决定要不要归档。这个动作成本很低,却能让你的沙箱从“黑盒”变成“可审计的黑盒”,对排查诡异问题特别有效。
该项目后续如果要做成服务,可以考虑把沙箱执行包裹成一个标准接口,入参是容器镜像、执行命令、资源配额、超时时间,出参是日志、退出码、产物哈希。这样不同 Agent 任务就能复用同一层沙箱执行引擎,而不至于每个业务线各搭各的,既浪费运维精力,也容易在配置上出现疏漏。