Agent代码沙箱技术:OpenSandbox和增强code-interpreter镜像
在 AI 应用里,让大模型“会写代码”并不难,难的是让它安全、稳定、可控地执行代码。
在项目中接入 OpenSandbox 的目标,不是做一个通用远程终端,而是给大模型一个受控的代码执行能力:读取用户上传的 Excel、CSV、Word、PDF 等文件,完成分析、计算、转换,再把生成结果以下载链接返回给用户。
这篇文章总结一下我目前使用 OpenSandbox 的一些技巧和踩坑经验。
1. 不把 OpenSandbox 原子 API 直接暴露给大模型
OpenSandbox 提供了很多底层能力,例如:
/files:文件上传、下载、搜索/directories:目录操作/code:代码执行/code/context:有状态代码执行/command:Shell 命令/metrics/watch:实时监控
一开始很容易想把这些接口一比一封装成 MCP tool 给大模型调用。但实际使用下来,我更倾向于只暴露一个高层工具:
sandbox_execute_command(language, script, outputFilePaths)
大模型只需要关心三件事:
- 用什么语言执行,当前只开放 Python 和 JavaScript。
- 要执行的完整脚本是什么。
- 是否需要生成输出文件,生成哪些文件。
会话 ID、上传文件路径、本地输出目录、下载链接生成,都由服务端处理,不交给大模型填写。
这样做的好处是边界清晰:大模型负责“写业务脚本”,服务端负责“安全执行和文件治理”。
2. sessionId 不让模型传,由 ToolContext 注入
沙箱执行必须和当前对话会话绑定,否则很容易出现跨会话读写文件的问题。
我的做法是:
- 对话入口保证每个 chat session 都有
sessionId。 sessionId放进 Spring AI 的ToolContext。- MCP tool 参数里不暴露
sessionId。 - 沙箱工具从
ToolContext读取sessionId。
这样大模型既不能伪造 session,也不会因为写错 sessionId 导致文件落错目录。
// ToolContext 只在服务端传递,不进入大模型 tool 参数;沙箱工具用它绑定会话和上传文件。
toolContext.put(TOOL_CONTEXT_SESSION_ID, sessionId);
toolContext.put(TOOL_CONTEXT_FILE_URLS, fileUrls);
这个设计很关键:模型能看到业务参数,但不能控制安全边界参数。
3. 一个会话复用一个 Sandbox,并设置生命周期清理
代码执行服务里维护了一个 ConcurrentHashMap<String, Sandbox>:
private final ConcurrentHashMap<String, Sandbox> sandboxes = new ConcurrentHashMap<>();
同一个 sessionId 下,如果沙箱还健康,就复用并续期;如果不健康,就关闭后重新创建。
if (cached != null && cached.isHealthy()) {
cached.renew(properties.getSandboxTimeout());
return cached;
}
这样可以减少频繁创建沙箱的成本,也为后续的交互式分析留下空间。
同时,会话结束时监听会话结束事件,主动清理沙箱:
@EventListener
public void onToolExecutionFinished(AiSessionFinishedEvent event) {
sandboxExecutionService.clearSandboxes(event.getSessionId());
}
这比单纯依赖超时更稳,避免沙箱长时间占用资源。
4. 使用持久化 workspace,而不是依赖内存状态
当前默认开启会话级持久化工作区:
open-sandbox:
persistent-workspace-enabled: true
workspace: /workspace
allowed-output-paths:
- /home/app/upload/sandbox/
映射关系大致是:
沙箱内:/workspace
宿主机:/home/app/upload/sandbox/{sessionId}
也就是说,同一个会话里,脚本生成的文件会保留在会话目录下。即使沙箱重建,只要重新挂载同一个目录,文件状态仍然在。
我现在更推荐这种“文件状态持久化”,而不是一开始就依赖 /code/context 的内存变量持久化。
原因是:
- 文件更容易审计。
- 文件更容易下载和复用。
- 文件不会因为内核重启直接丢失。
- 财务、表格、文档分析这类场景,本来就天然以文件为中间产物。
/code/context 更适合 Notebook 式连续分析,例如连续复用 df、result、辅助函数。它有价值,但不建议默认替代当前的一次性脚本执行模式。
5. 输入文件由服务端解析并上传到 /workspace
用户上传文件时,大模型看到的通常是文件 URL。我的处理方式是:
- 从
ToolContext.fileUrls读取上传文件。 - 通过业务文件服务解析成本地文件。
- 校验文件类型是否支持。
- 上传到沙箱
/workspace。 - 返回模型真实可访问的沙箱路径。
支持的输入类型包括:
xlsx, xls, csv, tsv, docx, doc, pdf, pptx, ppt,
txt, md, html, json, xml, yaml 等
同时对文件名做安全处理:
- 替换控制字符。
- 替换
/和\。 - 限制文件名长度。
- 同名文件自动改成
xxx_2.xlsx、xxx_3.xlsx。
这样可以避免多个上传文件在沙箱中互相覆盖。
6. 输出文件路径不让模型写死,而是注入 OUTPUT_FILES
这是整个方案里最实用的技巧之一。
大模型不应该在脚本里写死:
df.to_excel("/workspace/result.xlsx")
而应该写:
df.to_excel(OUTPUT_FILES[0], index=False)
服务端根据 outputFilePaths 计算真实输出路径,并在脚本前注入:
OUTPUT_DIR = "/workspace"
OUTPUT_FILES = ["/workspace/report.xlsx"]
JavaScript 也是类似:
const OUTPUT_DIR = "/workspace";
const OUTPUT_FILES = ["/workspace/report.xlsx"];
这样做可以保证:
- 模型生成路径和服务端下载路径一致。
- 输出文件一定落在当前会话目录。
- 多文件输出时顺序明确。
- 不需要让模型理解宿主机真实路径。
7. 不做“唯一文件兜底下载”
有些代码执行工具会在脚本没写到指定输出文件时,自动搜索 workspace 里唯一的新增文件并下载。
我没有这么做。
如果期望输出是:
/workspace/result.xlsx
但模型实际写成了:
/workspace/abc.xlsx
工具会返回明确错误:
输出文件未生成,期望脚本写入:/workspace/result.xlsx。
检测到脚本可能生成了唯一非输入文件:/workspace/abc.xlsx。
请让脚本写入 /workspace/result.xlsx,或把 outputFilePaths 改为 abc.xlsx。
工具不会自动兜底下载,避免多文件误匹配。
这看起来“不够智能”,但更安全。
尤其在多文件场景下,自动猜测很容易下载错文件。工具应该帮模型定位问题,而不是替模型做不确定的决定。
8. 本地路径必须做白名单和真实路径校验
代码执行工具最容易出问题的地方是文件路径。
当前实现里分了两个白名单:
open-sandbox:
allowed-local-paths:
- /home/app/upload/
allowed-output-paths:
- /home/app/upload/sandbox/
含义是:
allowed-local-paths:允许作为输入读取的本地目录。allowed-output-paths:允许作为输出写入的本地目录,范围更小。
输出文件最终会被解析到:
allowed-output-paths/{sessionId}/xxx
绝对路径也允许,但必须已经位于当前会话目录内。相对路径则统一挂到当前 session 输出目录下。
此外,还要防几类绕过:
../路径跳出当前目录。- session 目录是符号链接。
- 输出文件本身是符号链接。
- 父目录通过符号链接绕到其他目录。
- 输出文件存在多个硬链接,覆盖时影响其他会话文件。
这些校验看起来繁琐,但非常必要。大模型写代码时不会天然有安全意识,服务端必须兜住边界。
9. 下载文件时用原子替换,避免半成品暴露
如果不开启持久化 workspace,工具会从沙箱读取文件内容,再写回本地输出目录。
这里我没有直接写目标文件,而是:
- 先写同目录临时文件。
- 再用 atomic move 替换目标文件。
- 不支持原子替换时降级为普通替换。
- 最后清理临时文件。
Files.move(tempPath, localPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
这样可以避免下载接口读到半截文件。
10. 自定义 Code Interpreter 镜像,提前补齐 Office/PDF 能力
官方 Code Interpreter 镜像能跑代码,但业务里经常要处理 Office 文档,所以我做了增强镜像。
系统依赖包括:
libreoffice-writer
libreoffice-calc
libreoffice-impress
poppler-utils
中文字体
Python 依赖包括:
pandas
numpy
openpyxl
xlsxwriter
xlrd
pyxlsb
odfpy
python-docx
python-pptx
pypdf
PyMuPDF
pdfplumber
reportlab
pillow
构建阶段还会预初始化 Jupyter kernelspec,避免每次沙箱启动时重复初始化 Python、JavaScript、TypeScript、Go、Bash 内核。
这是一个很实际的优化:代码执行工具的首轮响应速度,往往决定用户是否觉得“能用”。 附上Dockerfile
FROM sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/code-interpreter:v1.0.2
USER root
ARG CODE_INTERPRETER_PYTHON_VERSION=3.13
ARG CODE_INTERPRETER_NODE_VERSION=20
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
libreoffice-writer \
libreoffice-calc \
libreoffice-impress \
poppler-utils \
fontconfig \
fonts-noto-cjk \
fonts-wqy-zenhei \
&& fc-cache -f || true \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN echo "code-interpreter build python selector: ${CODE_INTERPRETER_PYTHON_VERSION}" \
&& source /opt/opensandbox/code-interpreter-env.sh python "${CODE_INTERPRETER_PYTHON_VERSION}" \
&& python3 -m pip install --prefer-binary --break-system-packages \
pandas \
numpy \
openpyxl \
xlsxwriter \
xlrd \
pyxlsb \
odfpy \
python-docx \
docxcompose \
mammoth \
python-pptx \
pypdf \
PyMuPDF \
pdfplumber \
pdfminer.six \
reportlab \
pillow \
lxml
COPY code-interpreter.sh /opt/opensandbox/my-code-interpreter.sh
RUN chmod +x /opt/opensandbox/my-code-interpreter.sh \
&& ln -sf /opt/opensandbox/my-code-interpreter.sh /opt/opensandbox/code-interpreter.sh \
&& CODE_INTERPRETER_SETUP_ONLY=1 PYTHON_VERSION="${CODE_INTERPRETER_PYTHON_VERSION}" NODE_VERSION="${CODE_INTERPRETER_NODE_VERSION}" /opt/opensandbox/my-code-interpreter.sh
WORKDIR /workspace
ENTRYPOINT ["/opt/opensandbox/my-code-interpreter.sh"]
11. 默认禁用沙箱出网
当前配置里默认关闭网络:
open-sandbox:
network-enabled: false
创建沙箱时下发 networkPolicy,默认拒绝出网。
这对企业内部知识库、文件分析、财务数据处理很重要。用户上传的文件进入沙箱后,不应该被脚本随意发往外网。
如果某些场景确实需要联网,应该做成明确配置,而不是默认开放。
12. 清理任务只扫沙箱输出目录
输出文件需要定期清理,但清理逻辑不能太激进。
我的做法是:
- 只扫描
allowed-output-paths。 - 额外扫描对话临时上传目录。
- 按最后修改时间清理过期文件。
- 空目录也必须超过保留时间才删除。
- 使用独立 daemon 线程,不依赖项目全局调度配置。
open-sandbox:
cleanup-enabled: true
output-retention: 24h
cleanup-interval: 1h
清理任务的边界一定要小,宁可少删,也不能误删业务上传目录。
13. 为什么暂时不直接开放 /command
OpenSandbox 的 /command 很强,但我不建议直接给大模型。
Shell 命令的风险比 /code 高很多:
- 可以操作更多系统命令。
- 更容易绕过业务约束。
- 输出和副作用更难控制。
- 大模型容易为了“方便”执行过宽命令。
如果以后确实需要 Shell 能力,我更倾向于做白名单命令工具,例如:
convert_document_to_pdf
inspect_file_type
list_workspace_files
而不是暴露一个通用 execute_shell_command。
14. /code/context 什么时候值得做
/code/context 的优势是变量能跨多次执行保留。
比如用户上传 Excel 后连续问:
- 先读取资产负债表。
- 再计算收入、成本、毛利率。
- 再按部门分组。
- 再换一个口径重新计算。
- 最后导出图表和报告。
这种交互式财务分析场景下,保留 df、清洗规则、字段映射和辅助函数,会明显提升体验。
但我不会让它替代当前的 sandbox_execute_command。更合理的方式是新增一个专用工具:
excel_data_analysis_context
并且加上约束:
context_id由服务端绑定sessionId。- 同一个 context 串行执行。
- 支持 reset。
- 返回当前变量摘要。
- 保留执行历史,方便最终生成可复现脚本。
- 设置 TTL 和内存上限。
简单说:一次性文件处理用 /code,多轮交互分析再上 /code/context。
15. 总结
- 大模型只写业务脚本,不控制安全参数。
- 文件输入输出都由服务端托管。
- 输出路径由服务端注入,不让模型写死。
- sessionId、上传文件、下载路径都不要暴露成模型参数。
- 默认禁用出网。
- workspace 可以持久化,内存 context 不急着默认开放。
- 失败时给模型明确修正建议,但不要自动猜测危险结果。
- 路径校验要按真实路径做,不能只看字符串前缀。
OpenSandbox 本身提供的是底层能力,真正落地到 AI 产品里,关键在于再包一层业务边界。
这层边界越清晰,模型越容易稳定完成任务,系统也越不容易出安全问题。
讨论
评论