从一次线上事故说起去年冬天凌晨两点我被值班电话炸醒。一个部署在客户内网的AI Agent在调用Python解释器执行用户输入的代码片段时直接执行了os.system(rm -rf /)——好在Docker容器里做了只读挂载但容器内的临时数据全部丢失导致后续任务链断裂。客户运维总监在电话里吼“你们这个Agent是自带核弹开关吗”那次事故之后我花了整整两周重构代码执行模块。今天这篇笔记就是当时踩坑的完整记录。为什么需要沙箱—— 不是所有代码都值得信任Agent的本质是“用代码生成代码并执行”。当你的Agent从LLM拿到一段Python脚本、一条Shell命令、甚至一个SQL查询时你面临三个层面的风险恶意破坏用户故意输入import shutil; shutil.rmtree(/)意外泄露Agent生成的代码里不小心打印了环境变量os.environ[DB_PASSWORD]资源耗尽while True: pass或者fork炸弹别指望LLM能帮你过滤——我见过GPT-4在生成代码时为了完成“删除临时文件”的任务直接写了个os.remove(/etc/passwd)。模型没有“边界感”它只知道完成任务。沙箱方案一Docker容器 —— 重型武器但最可靠基础配置别用默认镜像# 别这样写FROM python:3.11-slim # 这样等于给Agent一把没上保险的枪 FROM python:3.11-alpine # 这里踩过坑必须删除所有敏感工具 RUN apk del curl wget git openssh-client \ rm -rf /usr/bin/python3 /usr/local/bin/pip* # 创建非root用户别用root跑代码 RUN adduser -D sandbox USER sandbox # 只读根文件系统临时目录挂tmpfs WORKDIR /home/sandbox COPY --chownsandbox:sandbox . .关键点镜像里不要留任何网络工具。我见过有人用python:3.11-slim结果Agent生成的代码里调用了urllib.request去下载恶意payload。Alpine镜像里连wget都删掉只保留Python解释器和标准库。运行时参数每个容器只活一次importdocker clientdocker.from_env()# 这里踩过坑不要复用容器每次执行都新建containerclient.containers.run(imageagent-sandbox:latest,command[python,-c,user_code],# 内存硬限制别让Agent吃光宿主机mem_limit256m,# CPU配额防止死循环拖垮整个Docker daemoncpu_period100000,cpu_quota50000,# 相当于0.5核# 网络完全隔离network_disabledTrue,# 只读根文件系统read_onlyTrue,# 临时目录挂tmpfs大小限制tmpfs{/tmp:size64m,noexec,nosuid},# 超时强制杀死timeout30,# 自动清理别让僵尸容器堆积auto_removeTrue,# 限制文件描述符数量ulimits[docker.types.Ulimit(namenofile,soft1024,hard1024)],)超时处理是血泪教训。我最初设了60秒超时结果有个Agent生成的代码在计算斐波那契数列时用了递归60秒内没跑完容器一直挂着。后来改成30秒强制SIGKILL并且用timeout参数让Docker daemon在超时后直接杀掉进程。输出捕获别相信stdoutimportsignal# 这里踩过坑直接container.logs()可能阻塞# 用streamTrue配合超时读取try:outputcontainer.logs(stdoutTrue,stderrTrue,streamFalse,tail1000)exceptdocker.errors.APIErrorase:# 容器被OOM killer杀掉时这里会抛异常outputf[SANDBOX ERROR] 容器异常退出:{str(e)}注意输出大小必须限制。我见过Agent生成的代码里有个print(a * 10**8)直接把Docker daemon的日志缓冲区撑爆。后来在容器启动参数里加了--log-opt max-size1m并且在Python层面截断输出到10KB。沙箱方案二Pyodide —— 浏览器里的轻量级方案为什么需要PyodideDocker方案太重了。在边缘设备、浏览器插件、或者需要毫秒级响应的场景下拉起一个Docker容器需要几百毫秒甚至几秒。Pyodide把CPython编译成WebAssembly直接在浏览器或Node.js里运行Python。初始化别每次都加载// 别这样写每次执行都加载Pyodide// 加载一次需要3-5秒用户会骂娘letpyodidenull;asyncfunctioninitPyodide(){if(pyodide)returnpyodide;pyodideawaitloadPyodide({indexURL:https://cdn.jsdelivr.net/pyodide/v0.25.0/full/,// 这里踩过坑不限制内存浏览器会卡死// 但Pyodide本身不支持硬限制只能靠外部监控});// 预加载常用库避免运行时下载awaitpyodide.loadPackage([numpy,pandas]);returnpyodide;}安全限制Python层面的“软墙”Pyodide运行在浏览器沙箱里天然无法访问文件系统和系统调用。但Python标准库里的os模块依然存在——虽然大部分函数会抛异常但有些操作比如os.environ能读取浏览器环境变量。# 在Pyodide里执行前先注入安全策略importsysimportbuiltins# 这里踩过坑直接del os模块但用户代码可能import os# 更好的做法是重写__import__original_importbuiltins.__import__defsafe_import(name,*args,**kwargs):# 黑名单禁止导入任何可能泄露信息的模块forbidden[os,subprocess,ctypes,socket,requests]ifnameinforbidden:raiseImportError(f模块{name}被安全策略禁止)returnoriginal_import(name,*args,**kwargs)builtins.__import__safe_import# 限制内置函数delbuiltins.opendelbuiltins.execdelbuiltins.compile注意这种Python层面的限制是“软墙”只能防君子。如果用户代码里用sys.modules[os]绕过或者用ctypes直接调用C库Pyodide本身的安全机制会兜底——但别完全依赖。超时与资源监控用Web Worker隔离// 别在主线程执行Python代码会卡死UI// 用Web Worker跑PyodideconstworkernewWorker(sandbox-worker.js);worker.postMessage({code:userCode,timeout:5000,// 5秒超时memoryLimit:128*1024*1024// 128MB});// 这里踩过坑worker.postMessage后直接await但worker可能不响应// 用Promise.race实现超时constresultawaitPromise.race([newPromise(resolveworker.onmessageresolve),newPromise((_,reject)setTimeout(()reject(超时),5000))]);Web Worker的好处是即使Pyodide内部死循环浏览器也能通过worker.terminate()强制杀死。但注意每次terminate后需要重新创建Worker因为Pyodide实例无法恢复。安全策略比沙箱本身更重要策略一代码静态分析在执行前先用AST解析用户代码做一次静态检查importastdefstatic_check(code:str)-bool:try:treeast.parse(code)exceptSyntaxError:returnFalse# 语法错误直接拒绝# 这里踩过坑只检查了import没检查getattr动态调用fornodeinast.walk(tree):# 禁止调用os.system、subprocess等ifisinstance(node,ast.Call):ifisinstance(node.func,ast.Attribute):ifnode.func.attrin[system,popen,run,check_output]:returnFalse# 禁止eval/execifisinstance(node,ast.Name):ifnode.idin[eval,exec,compile]:returnFalse# 禁止循环无限制ifisinstance(node,(ast.While,ast.For)):# 简单启发式没有break的循环直接拒绝has_breakany(isinstance(n,ast.Break)forninast.walk(node))ifnothas_break:returnFalsereturnTrue静态分析不能替代沙箱但可以作为第一道防线。我见过有人用getattr(os, system)(rm -rf /)绕过静态检查——所以静态分析只能过滤明显恶意代码真正的安全还得靠沙箱。策略二能力最小化原则能力Docker方案Pyodide方案网络完全禁用浏览器自带限制文件系统只读tmpfs无文件系统进程创建禁止无进程概念环境变量清空只保留必要变量系统调用seccomp过滤WASM沙箱Docker的seccomp配置是很多人忽略的{defaultAction:SCMP_ACT_ERRNO,architectures:[SCMP_ARCH_X86_64],syscalls:[{names:[read,write,exit,exit_group,brk,mmap,munmap],action:SCMP_ACT_ALLOW}]}只允许最基本的系统调用连clone创建进程都禁止。这样即使代码里调用了os.fork()也会直接返回错误。策略三审计日志所有代码执行请求必须记录importloggingimportuuiddefexecute_code(user_id,session_id,code):exec_iduuid.uuid4().hex[:8]logging.info(f[EXEC]{exec_id}| user{user_id}| session{session_id})logging.debug(f[EXEC]{exec_id}| code{code[:200]}...)try:resultsandbox.run(code,timeout30)logging.info(f[EXEC]{exec_id}| success | output_len{len(result)})returnresultexceptExceptionase:logging.error(f[EXEC]{exec_id}| failed | error{str(e)})raise审计日志不是为了追责是为了事后分析。那次rm -rf事故后我翻日志发现那个用户之前执行过os.listdir(/)——如果当时有告警规则就能提前拦截。个人经验性建议别在沙箱里放任何敏感数据。我见过有人把数据库密码写在环境变量里传给Docker容器结果Agent生成的代码里有个print(os.environ)密码直接暴露在日志里。正确的做法是沙箱里只放执行代码所需的最小数据敏感信息通过外部服务注入。Docker和Pyodide不是二选一。我现在的架构是后端用Docker跑重型任务数据处理、模型推理前端用Pyodide跑轻量级代码数据可视化、简单计算。两者共用同一套安全策略定义只是实现方式不同。永远假设沙箱会被突破。Docker的容器逃逸漏洞每年都有Pyodide的WASM沙箱也不是绝对安全。所以沙箱里不要放任何有价值的数据执行结果要经过二次校验比如检查输出里是否包含敏感信息并且所有操作都要有熔断机制。性能不是借口。有人跟我说“Docker太慢所以不用沙箱”。我见过最离谱的案例一个Agent直接在生产服务器上exec()用户代码理由是“就几个用户不会有问题”。结果那个“几个用户”里有个安全研究员直接os.system(cat /etc/shadow)把密码文件拖走了。安全是底线不是可选项。测试你的沙箱。写一个自动化测试脚本里面包含rm -rf /、while True: pass、fork()、import socket; socket.connect((evil.com, 80))、open(/etc/passwd).read()。每次修改沙箱配置后跑一遍这个测试集。如果任何一个测试没被拦截说明你的沙箱有漏洞。最后说一句AI Agent的代码执行能力本质上是把一把枪交给了一个不懂安全的小孩。你的沙箱就是保险栓——别让它形同虚设。