Skip to main content

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_usageAgentEvent 流、按 tag 筛容器
可审计 每次会话自动归档到 /tmp/SandboxAgent/默认开

驱动内置 OpenCodeDriverCodexDriverClaudeCodeDriver。接别的 agent CLI 就继承 BaseDriver 实现那 11 个抽象成员。

driver 能碰容器的只有三个原语ContainerOpsrun / 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 36000 = 不自杀)
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

sandbox_agent-0.2.0.tar.gz (100.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sandbox_agent-0.2.0-py3-none-any.whl (99.5 kB view details)

Uploaded Python 3

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

Hashes for sandbox_agent-0.2.0.tar.gz
Algorithm Hash digest
SHA256 239c15b70bd8e14355fdd6ed341653ec144d1f748b047935b13b8fdbf5371366
MD5 6d2703511fcbf69564bd43cbd624175d
BLAKE2b-256 d749eed8d5fa6a11a05f47cfcaa9e68dead9fe01b5913cd9ecc735def738e755

See more details on using hashes here.

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

Hashes for sandbox_agent-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7c9bde13738996402e2e0da211a323a84edcf25eb8d9712d88b247e57d5014e3
MD5 799b596bf20d35c251d691b4e1ae1f4d
BLAKE2b-256 8ef62cd659a6944a3365478f8ec976c6ce6648549ecefcfa569fd9f3ef4dce91

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 files

0.0.14

2 files

0.0.13

2 files

0.0.12

2 files

0.0.11

2 files

0.0.10

2 files

0.0.9

2 files

0.0.8

2 files

0.0.7

2 files

0.0.6

2 files

0.0.5

2 files

0.0.4

2 files

0.0.3

2 files

0.0.2

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page