sandbox-agent
把 LLM agent CLI 关进一次性 Docker 容器里跑。
agent 会写文件、跑命令、装依赖。让它在你的开发机上直接干这些事是个坏主意。 这个库给它一个用完就扔的容器,并把结果安全地取出来。
零运行时依赖,只要求宿主机有 docker。
from SandboxAgent import SafeAgent, OpenCodeDriver
driver = OpenCodeDriver(provider="zhipuai-coding-plan", api_key=API_KEY)
with SafeAgent(driver=driver, model="zhipuai-coding-plan/glm-5-turbo") as agent:
print(agent.ask("用 Python 写个快排, 存到 /tmp/qs.py"))
code, out = agent.container.exec(["python", "/tmp/qs.py"]) # 验收产出
agent.container.download("/tmp/qs.py", "./qs.py") # 取回宿主机
# 退出 with 块 -> 容器销毁
安装
uv add sandbox-agent # 或 pip install sandbox-agent
还需要一个装好了 agent CLI 的镜像,见 docker/ 与 .ci/build_docker.sh。
包和镜像的对应关系
装了哪个版本的包,就唯一确定了该拉哪个镜像。 不靠约定,靠推导:
pip install sandbox-agent==0.1.4
└─ wheel 里带着 src/SandboxAgent/versions.json
claude_version = 2.1.235
image_date = 20260819
└─ donaldtrump/sandbox-agent:claude-2.1.235-20260819
tag 格式就是 {repo}:{cli}-{cli版本}-{image_date},三段都来自那一份
versions.json——构建脚本和 Python 运行时读的是
同一份,两边各存一份就会得到一个名字和内容对不上的镜像,而且不报错。
几条刻意的选择:
- 包版本不进 tag。 快照内容已经唯一确定了 tag,再把「这是哪个快照」编码进去是
多绕一圈。反过来
pyproject.toml也不读versions.json:打包版本是打包的事, 镜像日期是运行时的事,粘在一个文件里只会互相绑架。 image_date是手写的声明,不是构建时观测的。 它管的是「三个 CLI 一个都没升, 但基础镜像要重建」那种情况——CLI 版本号挡不住,日期能,而且比-1-2有信息量。 构建脚本里写date +%Y%m%d会让运行时算出的 tag 指向一个从没构建过的镜像, 且不报错,所以日期只读不算(有测试盯着)。- 不用
latest。 同一份代码在不同时间跑出不同行为,「验证过」三个字就没了意义。 构建脚本只推钉死的 tag。
代价是人得记得改日期,所以 build_docker.sh --push 会先查 registry,tag 已存在就
拒绝推送(同一天反复调试用 --force);build_pypi.sh --push 会反过来查三个镜像
是否都已就位,缺一个就拒绝发布。发布顺序被钉死了:先镜像,后包。
不传 image= 时用 driver 自己的默认镜像——每个 agent CLI 有自己的镜像,一个全局
默认值只能对其中一个是对的。优先级:显式 image= > SANDBOX_AGENT_IMAGE_NAME
(换私有 registry 用)> driver.default_image > 内置默认值。
本地 LLM 服务(可选)
runtime/litellm/ 是一个独立的 compose 栈,把 CNB 的 CodeBuddy
网关包装成本地的 OpenAI 兼容 API:
cd runtime/litellm && docker compose up -d --build # -> http://127.0.0.1:4000/v1
跟本库的 SafeAgent 没有代码耦合,各起各的。想让沙箱容器用上它,见那边的 README。
能干什么
| 对话 | chat() 拿可迭代的流式事件,推进主线;ask() 开旁支问一句,问完即扔 |
| 会话 | sessions.list() / .fork() / .dump() / .load() —— 会话能跨容器搬 |
| 容器操作 | container.upload() / .download() / .read() / .write() / .exec() |
| 能力注入 | skills.add() / .add_many() / .list(),rules.install() |
| 可观测 | token_usage、AgentEvent 流、按 tag 筛容器 |
| 可审计 | 每次会话自动归档到 /tmp/SandboxAgent/,默认开 |
驱动内置 OpenCodeDriver、CodexDriver 与 ClaudeCodeDriver。接别的 agent CLI
就继承 BaseDriver 实现那 11 个抽象成员。
driver 能碰容器的只有三个原语(ContainerOps:run / read_file /
write_file)——driver 不该知道 docker 存在。这也意味着 driver 的单测只要伪造三个
方法,不用 mock 一个上千行的类。
想连容器一起换(换成 podman、换成远程执行器),那是另一个扩展点:按
ContainerRuntime 那 11 个方法写一个,传给 SafeAgent(runtime_factory=...)。
完整 API 见 docs/sandbox_agent.md,可跑的完整示例见 main.py:
uv run python main.py # 全套 opencode demo, 十几个容器几分钟
uv run python main.py --fast # 只跑第一个, 够验证"改完还能跑"
uv run python main.py --claude # 只跑 ClaudeCodeDriver 那个
前两档要 ZHIPUAI_CODING_TOKEN 和装好 opencode 的镜像;--claude 两样都不要,
但要 claude 镜像和一个 Anthropic 兼容网关(runtime/litellm/ 就是)。
会话审计
每次会话自动落盘,事后能查 agent 到底做了什么。默认开着——出事之后才想起来 打开的开关等于没有。
/tmp/SandboxAgent/20260819/ses_9f2/
events.jsonl 抽象事件,一行一个
session.jsonl 原始 stdout 行,逐字节照抄
status.json 元数据 + 每一轮的结果
两个文件回答两个不同的问题:
# agent 干了什么 —— 三个 CLI 归一之后的形式,读这个就够
jq -r '"\(.kind)\t\(.payload.command // .payload.path // .payload.text)"' events.jsonl
tool.exec printf 'sandbox-agent-io-ok\n' > /workspace/_claude_demo.txt
text done
skill Launching skill: zebra-protocol
text zebra code 是 ZC-8842。house rule 是 HR-7391。
工具调用、参数、结果、最终回答——「agent 干了什么」一条不漏。
thinking 事件:取决于后端,真 Anthropic API 上拿不到
EventKind 里有 thinking,但它在真 Anthropic API 上一条都不会产生。实测
(非流式 / 流式 SSE / 真 CLI 过沙箱,三条路径一致):
content 块类型: ['thinking', 'text']
thinking 键 : ['signature', 'thinking', 'type']
thinking 长度 : 0 ← 正文是空的
usage.thinking_tokens: 77 ← 但它确实思考了 77 个 token
模型思考了、计费了、块也返回了,正文被抹掉,只留一个 signature(那是给多轮
续接做完整性校验的,不是给人读的)。所以 thinking 事件只在转发推理模型的
OpenAI 兼容网关下才有内容——比如 runtime/litellm/ 那套把
DeepSeek 接成 Anthropic 协议的栈,DeepSeek 会把原始推理链吐出来。
有内容时,多个 thinking 块按原顺序各成一条事件,不合并:那个「想 → 调工具 → 再想」 的交错顺序本身就是信息。
# driver 归对了没有 —— 某条事件是从哪一行解析出来的
sed -n "$(jq -r 'select(.kind=="other") | .line' events.jsonl)p" session.jsonl
session.jsonl 是逐字节照抄,不做任何过滤。system/thinking_tokens 那种进度
心跳照样全量落盘——真 Anthropic API 下一轮只有个位数,但经网关转发推理模型时能占到
97% 的行。那是它的原始输出,我们不替你判断什么重要。要过滤是读的时候的事:
jq -c 'select(.subtype != "thinking_tokens")' session.jsonl
events.jsonl 的每条带一个 line 字段回指 session.jsonl 的行号——两个文件行数
必然对不上(step_start 这类不产出事件),一个整数把对应关系还回来。
同一个 session_id 的多轮对话落进同一个目录,两个 jsonl 跨轮追加。流式写入,
跑着的会话可以直接 tail -f。
SafeAgent(driver=driver, audit=False) # 显式关闭
目录和保留天数是 audit 模块的两个常量:
from SandboxAgent import audit
audit.AUDIT_ROOT = "/data/sandbox-audit"
audit.RETENTION_DAYS = 30 # 0 = 永不清理
清理按天数:早于 today - N 的整个日期目录删掉,每进程跑一次。日期分区让这件事
退化成一次 listdir 加比字符串,不用 walk 也不用 stat;名字不像日期的目录一概不碰。
归档失败不会影响 chat。 可观测性功能不能反过来搞死被观测的东西:磁盘满了就发 一条
RuntimeWarning然后自己降级成 no-op,该跑的照跑。
ask()的旁支在容器里删了,归档留着。 审计的意义就是留痕。
容器生命周期
容器闲置 keepalive_seconds(默认 3600s)后自杀,不是"最多活一小时"——
只要还有命令在跑就会一直续期。正常路径下 close() / with 退出即销毁,
自杀只是兜底,防止调用方崩溃后留下垃圾。
配置
优先级:环境变量 > ~/.config/safe_agent/config.json > 内置默认值。
| 环境变量 | JSON key | 默认值 |
|---|---|---|
SANDBOX_AGENT_IMAGE_NAME |
image |
driver 自己的 default_image(见下) |
SANDBOX_AGENT_RESOURCE_PREFIX |
prefix |
safe-agent |
SANDBOX_AGENT_CONTAINER_MEMORY |
memory |
1g |
SANDBOX_AGENT_CONTAINER_CPUS |
cpus |
1.0 |
SANDBOX_AGENT_KEEPALIVE_SECONDS |
keepalive_seconds |
3600(0 = 不自杀) |
SANDBOX_AGENT_MAX_CONTAINERS |
max_containers |
20 |
SANDBOX_AGENT_START_TIMEOUT |
start_timeout |
120 |
SANDBOX_AGENT_STOP_TIMEOUT |
stop_timeout |
30 |
SANDBOX_AGENT_EXEC_TIMEOUT |
exec_timeout |
60 |
SANDBOX_AGENT_CHAT_TIMEOUT |
chat_timeout |
300 |
SANDBOX_AGENT_SESSION_TIMEOUT |
session_timeout |
120 |
SANDBOX_AGENT_TRANSFER_TIMEOUT |
transfer_timeout |
300 |
所有带 timeout 的方法参数一律 None = 用这类操作的配置预算。落到哪一项取决于
操作的性质:短命令是常数耗时,会话操作随会话长度增长,传输随字节数增长 —— 三种预算,
一条规则。
开发
uv sync --all-extras
uv run pytest # 单元测试, 全 mock, 不碰 docker
uv run pytest -m integration # 集成测试, 起真容器, 约 100s
集成测试默认跳过。它们用 debian:stable-slim(不需要 agent CLI),
可用 SANDBOX_AGENT_TEST_IMAGE 换掉。busybox 的 timeout 超时不返回 124,
别拿 alpine 顶替。
镜像必须事先拉好——测试不会替你 pull(那会让一次"只跑单测"的命令悄悄卡在 一个几百秒的下载上)。没拉的话集成测试会跳过,并在输出里给出要敲的命令:
docker pull debian:stable-slim
已知限制与后续计划见 TODO.md,变更历史见 CHANGELOG.md。
发布
顺序不能反——包里的 versions.json 指向镜像,镜像还没推包就发不出去
(build_pypi.sh 会拒绝):
# 0. 改了 CLI 版本或动了 Dockerfile.base? 先 bump src/SandboxAgent/versions.json
# 的 image_date —— 它是手写的,没人替你算
./.ci/build_docker.sh --push # 1. 镜像(tag 已存在会拒绝,加 --force 覆盖)
./.ci/build_pypi.sh --push # 2. 包(三个镜像不齐会拒绝)
License
GPL-3.0-or-later
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sandbox_agent-0.2.0.tar.gz.
File metadata
- Download URL: sandbox_agent-0.2.0.tar.gz
- Upload date:
- Size: 100.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
239c15b70bd8e14355fdd6ed341653ec144d1f748b047935b13b8fdbf5371366
|
|
| MD5 |
6d2703511fcbf69564bd43cbd624175d
|
|
| BLAKE2b-256 |
d749eed8d5fa6a11a05f47cfcaa9e68dead9fe01b5913cd9ecc735def738e755
|
File details
Details for the file sandbox_agent-0.2.0-py3-none-any.whl.
File metadata
- Download URL: sandbox_agent-0.2.0-py3-none-any.whl
- Upload date:
- Size: 99.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.5 {"installer":{"name":"uv","version":"0.12.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7c9bde13738996402e2e0da211a323a84edcf25eb8d9712d88b247e57d5014e3
|
|
| MD5 |
799b596bf20d35c251d691b4e1ae1f4d
|
|
| BLAKE2b-256 |
8ef62cd659a6944a3365478f8ec976c6ce6648549ecefcfa569fd9f3ef4dce91
|