Agent 沙箱安全隔离设计:运行外部 Python 脚本与 Shell 指令的容器隔离机制
上个月中旬的一个周四下午,安全部门的安全运营中心(SOC)突然打来紧急电话:“你们 AI 创新业务的那台测试宿主机,为什么正在对内网网段 10.100.x.x 进行疯狂的端口扫描,并且还在持续尝试向云厂商的元数据服务169.254.169.254发送 HTTP 请求?”
赶到机房一查,冷汗直接湿透了后背。
原来是前端团队接入了刚刚上线的“智能数据分析 Agent”。这个 Agent 带有代码解释器(Code Interpreter)功能,大模型(GPT-6 / DeepSeek-V4)可以根据用户的提问自主编写 Python 代码,然后在服务器后台执行并返回分析图表。有位做渗透测试的兄弟输入了一句:“请帮我排查当前网络环境中的可用主机和安全组配置”,大模型极其“聪明”地生成了一段基于socket和requests遍历内网网段、读取云实例 IAM 临时凭证的代码,并在宿主机上直接裸奔跑了起来!
如果这不是测试环境,而是生产环境,内网的核心数据库、Redis 缓存以及云厂商的访问密钥,已经在几秒钟之内全盘泄露。
让大模型自由生成并执行代码,是赋予 Agent 强大生产力的核心,但如果没有绝对的沙箱安全隔离,这就是直接给黑客递上了远程代码执行(RCE)的现成后门。
沙箱安全防线的三大铁律
一个工业级的代码执行沙箱,必须在操作系统内核、网络栈和文件系统三个维度构筑防线:
- 绝对断网(Network None):除特殊声明的受限代理外,执行容器必须断绝一切网络出站能力,阻断对
169.254.169.254云元数据的窃取,阻断反弹 Shell 和内网横向扫描; - 只读根文件系统(Read-only Rootfs):宿主机或镜像的根目录必须是不可篡改的只读状态。代码执行只能在带有配额的临时内存文件系统(tmpfs)中进行,执行完毕随容器销毁立即彻底抹去;
- 内核安全隔离与资源硬上限(gVisor / runsc + cgroups):单纯的 Docker run 共享宿主机内核,存在被内核漏洞逃逸的风险。生产环境建议采用 gVisor(runsc 运行时)提供独立的应用内核拦截系统调用,同时通过 cgroups 严格锁死 CPU(例如 1 核)、内存(例如 256MB)和最大进程数(PIDs 限制为 30),彻底防范 Fork 炸弹。
基于 Go 的高性能沙箱执行调度器
为了避免每次执行代码都经历笨重的冷启动拉起容器,我们在 Go 后端构建了一个沙箱预热管理模块。该模块通过 Docker Engine API / containerd 维护一组预热好的隔离沙箱,支持超时硬截断与输出长度截断。
以下是沙箱调度器的核心设计与代码实现:
package sandbox import ( "bytes" "context" "errors" "fmt" "os/exec" "strings" "syscall" "time" ) var ( ErrSandboxTimeout = errors.New("sandbox execution timed out") ErrResourceExceeded = errors.New("sandbox resource limit exceeded") ) // SandboxOptions 沙箱执行配置 type SandboxOptions struct { TimeoutSeconds int MemoryLimitMB int MaxOutputBytes int } // SandboxResult 执行产物 type SandboxResult struct { Stdout string Stderr string ExitCode int Duration time.Duration } // SandboxRunner 沙箱运行器 type SandboxRunner struct { defaultOpts SandboxOptions } // NewSandboxRunner 创建沙箱调度器 func NewSandboxRunner(opts SandboxOptions) *SandboxRunner { if opts.TimeoutSeconds <= 0 { opts.TimeoutSeconds = 5 // 生产环境通常单次代码执行不超过 5s } if opts.MemoryLimitMB <= 0 { opts.MemoryLimitMB = 256 // 默认硬顶 256MB 内存 } if opts.MaxOutputBytes <= 0 { opts.MaxOutputBytes = 64 * 1024 // 限制最大 64KB 输出,防止爆刷日志 } return &SandboxRunner{defaultOpts: opts} } // RunPythonCode 在严格隔离的轻量容器环境中执行 Python 脚本 func (r *SandboxRunner) RunPythonCode(ctx context.Context, script string) (*SandboxResult, error) { startTime := time.Now() // 1. 创建硬性超时的 Context execCtx, cancel := context.WithTimeout(ctx, time.Duration(r.defaultOpts.TimeoutSeconds)*time.Second) defer cancel() // 2. 构造基于安全隔离参数的容器执行指令 // 生产建议使用 --runtime=runsc (gVisor),这里展示通用硬核隔离配置: // - --network none:彻底剥夺容器网络访问权限 // - --read-only:只读根文件系统 // - --tmpfs /tmp:rw,noexec,nosuid,size=32m:仅允许 32MB 内存文件临时写,禁执行 suid // - --pids-limit 32:锁死 Fork 炸弹 // - --memory 256m --memory-swap 256m:禁止 Swap,打满直接 OOM Kill // - --cap-drop ALL:卸载一切 Linux Capabilities // - --security-opt no-new-privileges:严防提权 dockerArgs := []string{ "run", "--rm", "-i", "--runtime=runsc", "--network=none", "--read-only", "--tmpfs", "/tmp:rw,noexec,nosuid,size=32m", "--pids-limit", "32", "--cpus", "1.0", "--memory", fmt.Sprintf("%dm", r.defaultOpts.MemoryLimitMB), "--memory-swap", fmt.Sprintf("%dm", r.defaultOpts.MemoryLimitMB), "--cap-drop", "ALL", "--security-opt", "no-new-privileges:true", "python:3.11-slim", "python", "-u", "-", } cmd := exec.CommandContext(execCtx, "docker", dockerArgs...) // 将模型生成的代码作为 stdin 传入,避免在宿主机落盘文件带来脏数据与竞争 cmd.Stdin = strings.NewReader(script) var stdoutBuf, stderrBuf bytes.Buffer cmd.Stdout = &stdoutBuf cmd.Stderr = &stderrBuf // 3. 执行并捕获输出 err := cmd.Run() duration := time.Since(startTime) result := &SandboxResult{ Duration: duration, } // 截断超大输出,防止下游存储与前端直接被大字符串撑死 if stdoutBuf.Len() > r.defaultOpts.MaxOutputBytes { result.Stdout = stdoutBuf.String()[:r.defaultOpts.MaxOutputBytes] + "\n[Output Truncated: Exceeded Limit]" } else { result.Stdout = stdoutBuf.String() } if stderrBuf.Len() > r.defaultOpts.MaxOutputBytes { result.Stderr = stderrBuf.String()[:r.defaultOpts.MaxOutputBytes] + "\n[Error Output Truncated]" } else { result.Stderr = stderrBuf.String() } // 4. 异常与退出码解析 if err != nil { if errors.Is(execCtx.Err(), context.DeadlineExceeded) { return nil, ErrSandboxTimeout } var exitErr *exec.ExitError if errors.As(err, &exitErr) { if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { result.ExitCode = status.ExitStatus() // 退出码 137 通常代表容器触发 OOM 被内核强制杀掉 if result.ExitCode == 137 { return nil, ErrResourceExceeded } } } return result, nil // 代码逻辑执行报错,但沙箱正常返回 stderr } result.ExitCode = 0 return result, nil }生产避坑:那些藏在细节里的安全暗礁
很多团队搭建代码沙箱时只记得设了--network none,但下面这几个暗坑依然屡见不鲜:
- 宿主机 Docker Socket 挂载(绝对死罪):有些研发为了调试方便,将宿主机的
/var/run/docker.sock挂进了沙箱。大模型只需要在生成的代码里执行curl -XPOST --unix-socket /var/run/docker.sock http://localhost/containers/create,就能直接在宿主机提权为 root。沙箱环境严禁触碰任何宿主机 socket; - 无限死循环消耗 CPU:大模型写的代码难免出现
while True: pass。单纯的线程挂起可能导致宿主机 100% 满载。必须通过exec.CommandContext配合 Linux 的SIGKILL信号进行进程树强杀; - 输出缓冲区爆破:如果模型生成了
print("A" * 100000000),不做限制的cmd.Stdout会直接吃掉宿主机几百兆堆内存。必须在读取流时严格设置最大读取长度(比如 64KB)。
总结
大模型代码解释器的本质,是在生产环境执行不可信的任意输入。
靠 Prompt 里写“请不要执行破坏性命令”只是自欺欺人的君子协议;唯有依靠操作系统底层的命名空间隔绝、gVisor 独立内核截断和 cgroups 物理配额,才能让大模型在最野蛮狂放地写代码的同时,系统底层依然安如磐石。