17 KiB
乾坤 · 魂镜安全沙箱服务(hunjing-sandbox / Sandbox Engine)
文化命名「乾坤」(Sandbox Engine),百工枢的隔离执行后端——为器用百工提供一个自成天地、越界即止的封闭执行世界。命名登记见
docs/contracts/contract-glossary.md§2.1。
Rust + axum 实现的 HTTP 微服务,为「百工 Tool Runtime」提供本地隔离的命令执行层。在系统中的定位:
flowchart LR
Backend["后端 FastAPI"] -->|POST /v1/exec| Sandbox["hunjing-sandbox (Rust)"]
Sandbox --> Child["受限子进程<br/>进程组 / 临时工作目录 / 超时"]
Child --> System["shell / python / 外部命令"]
Sandbox -->|SIGKILL 进程组| Cleanup["超时或取消时清理子进程"]
安全边界:沙箱只执行已授权任务,不做风险判定(由司命 Safety 负责);调用方应在发起前完成权限与风险审核。设计依据见
docs/architecture/service-boundaries.md与docs/architecture/service-boundaries.md。
司命授权
/v1/exec 受 HMAC 授权中间件保护(/healthz 不受保护)。调用方(后端百工客户端)需经司命审核后签发短期凭证,沙箱端验签通过才执行。
- 凭证格式:
<payload_b64url>.<sig_b64url>,payload 含exp / trace_id / risk_level / tool / character_id / run_id / body_hash / rlimit_overrides / file_op_hash,sig 为HMAC-SHA256(SANDBOX_AUTH_SECRET, payload_b64url)。 rlimit_overrides:司命按 tool policy 授予的资源配额上限(key:fsize/as/cpu/nproc/nofile),随 HMAC 不可伪造;sandbox 验签后 clamp 到全局天花板SANDBOX_RLIMIT_*_MAX。属授权属性,不进body_hash。- 凭证轮换:
SANDBOX_AUTH_SECRET_PREVIOUS(逗号分隔旧 secret)支持多 secret 并存的无中断轮换——sandbox 先加 previous 认新旧 → backend 切新 secret → TTL 后清 previous。metricsandbox_auth_secret_used{slot=primary|previous}指示轮换进度。 body_hash为SHA256(canonical JSON {argv, stdin}),把凭证绑死到具体执行内容——凭证在 TTL 内无法被重放去执行任意其他命令;两端字节级一致(共享固定向量回归测试守护,见sandbox/src/auth.rs与backend/.../sandbox/auth.py)。file_op_hash为SHA256(canonical JSON {op, path}),把文件 IO 凭证绑死到具体操作(read/write/list/stat)与路径——TTL 内无法重放去读写任意其他文件;与body_hash互斥(exec 凭证此字段为 null),两端字节级一致(同样由固定向量回归守护)。- HTTP 头:
Authorization: Bearer <token>。 - 校验失败统一返回
401 {"error":{"code":"sandbox_unauthorized", ...}}。 SANDBOX_AUTH_ENABLED=false时跳过校验(仅本地联调,生产禁用)。
后端客户端实现见 backend/app/integrations/sandbox/(签发 + 调用 + 一炁事件回写,两端 HMAC 字节级一致,已通过跨语言闭环验证)。
隔离措施
| 维度 | 措施 |
|---|---|
| 进程 | 独立进程组(process_group(0)),超时/取消对整组 SIGKILL,清理派生子进程 |
| 环境 | 默认 env_clear,仅注入 SANDBOX_ALLOWED_ENV 白名单 + 请求指定变量 |
| 工作目录 | 默认每次执行用 base 下临时目录(执行后回收);可指定 base 内子目录,拒绝路径逃逸;文件类工具由百工 runtime 按 run/trace 隔离为 workspace/<run_key>(key 经 sanitize,防路径注入) |
| 超时 | min(请求 timeout_ms, 全局 SANDBOX_MAX_TIMEOUT_MS),到点强制结束 |
| 客户端断开 | 流式执行(/v1/exec/stream)感知接收端关闭(rx drop / SSE 断连)后主动对进程组 SIGKILL 取消并收尸,避免已离开用户的长任务(如 sleep 30)占满并发闸;覆盖无输出命令(capture_streaming 的 send-err 检测对无输出命令失效) |
| 输出 | stdout / stderr 各自截断至 max_output_bytes,超出标记 truncated 并继续 drain 防管道阻塞 |
| 残留清理 | 启动时清理 base 下 .tmp* 临时目录(tempfile 异常退出残留),不触及 workspace/ 持久目录 |
| 资源(进程) | fork 后、exec 前 setrlimit 限制单文件写入 / 虚拟内存 / CPU / 子进程数 / 打开文件数(软=硬),与容器限额形成纵深;配额分层(全局天花板 → 凭证授予 → 请求收紧)见下文 |
| 资源(容器) | Docker 侧 deploy.resources.limits 约束 memory / cpus / pids,/tmp 走 tmpfs 限大小 |
| 网络(容器) | sandbox 仅接入 internal 专属网络 hunjing-sandbox-net,无公网出站,防数据外泄;后端经该网络以服务名访问 |
| 权限(容器) | 只读根文件系统(仅 /tmp tmpfs 与 sandbox-work bind mount 可写)、no-new-privileges、cap_drop: ALL、Docker 默认 seccomp profile |
HTTP API
GET /healthz
{ "status": "ok", "service": "hunjing-sandbox", "version": "0.1.0" }
GET /metrics
Prometheus 指标(text/plain; version=0.0.4),不鉴权,供抓取:
| 指标 | 类型 | 含义 |
|---|---|---|
sandbox_exec_total{outcome} |
counter | 同步执行次数(completed/timeout/cancelled/failed) |
sandbox_exec_duration_seconds |
histogram | 同步执行耗时分布(含 _bucket/_sum/_count) |
sandbox_concurrent |
gauge | 当前并发执行数 |
sandbox_concurrent_limit |
gauge | 并发上限(SANDBOX_MAX_CONCURRENT) |
sandbox_stream_total{outcome} |
counter | 流式执行次数 |
sandbox_auth_secret_used{slot} |
counter | 验签命中 secret 分布(primary/previous),指示轮换进度 |
sandbox_auth_enabled |
gauge | 鉴权是否开启(1/0);值为 0 时应告警(生产环境鉴权被关) |
POST /v1/exec
请求体:
{
"argv": ["python3", "-c", "print(1 + 1)"],
"stdin": "可选标准输入",
"timeout_ms": 5000,
"workdir": "可选,base 内的相对子目录",
"env": { "GREETING": "hi" },
"max_output_bytes": 1048576,
"trace_id": "可选,透传用"
}
响应体:
{
"ok": true,
"exit_code": 0,
"stdout": "2\n",
"stderr": "",
"duration_ms": 42,
"timeout": false,
"cancelled": false,
"truncated": false,
"trace_id": "sbx_18f3a1...",
"isolation_mode": "process"
}
支持两种互斥的执行形态:
- 命令模式:
argv(首个元素为可执行文件)。 - 代码模式:
code+language(默认python,P0 仅支持 python)。沙箱转译为 argv(写临时脚本文件 + interpreter)后走执行路径;凭证body_hash按SHA256(canonical JSON {language, code, stdin})绑定。
argv 与 code 同时或都不给出返回 400。响应体 isolation_mode 字段(process / container)回显实际生效的隔离模式:P0 期间凭证下发 container 一律降级 process 并 warn(L3-L4 真正走容器留待 P1)。
错误统一格式(见跨语言契约):
{ "error": { "code": "exec_timeout", "message": "执行超时" } }
超时不计入错误码:响应 HTTP 200 且
timeout: true(执行被 SIGKILL 中止,exit_code为 null),调用方据timeout字段判断。
| code | HTTP | 含义 |
|---|---|---|
bad_request |
400 | argv/code 互斥冲突、argv 为空、language 不支持等参数错误 |
sandbox_unauthorized |
401 | 授权凭证缺失 / 过期 / 签名无效 / body_hash 不匹配 |
not_found |
404 | cancel 的 trace_id 不存在或已完成 |
service_unavailable |
503 | 并发闸耗尽 |
exec_failed |
500 | spawn / 等待失败等 |
POST /v1/cancel
按 trace_id 取消运行中的任务(对进程组 SIGKILL)。请求体 { "trace_id": "..." },命中返回 { "cancelled": true, "trace_id": "..." };任务不存在或已完成返回 404 not_found。被取消的 /v1/exec 调用会以 cancelled: true、ok: false 返回。
安全权衡:cancel 仅中止任务、不执行新命令,危害有限,故凭证验签 + 过期即可,不绑定 trace_id(与 exec 的 body_hash 强绑定不同)。调用方需自行记录 trace_id——exec 未传 trace_id 时由沙箱生成,客户端无从获知,无法取消。
POST /v1/exec/stream
流式执行(SSE):请求体同 /v1/exec,响应为 text/event-stream,实时推送:
event: stdout/event: stderr:data 为 base64(urlsafe-no-pad) 编码的原始字节增量(保真,前端解码为 UTF-8)event: done:data 为 JSON{exit_code, duration_ms, timeout, cancelled, truncated, trace_id},标志执行结束- 心跳:每 15s 一个注释行(KeepAlive),防代理空闲断开
后端 POST /tools/{name}/invoke/stream(sse-starlette)转发为 tool.stdout / tool.stderr / tool.completed / tool.requires_confirmation / tool.failed 事件;前端用 fetch+ReadableStream 消费(EventSource 不支持 POST body)。started/completed 落一炁事件存储,stdout/stderr 增量只透传不落库。
文件 IO(/v1/file/read · /write · /list · /stat)
不经 shell、二进制安全的 base 内文件读写(与 /v1/exec 的 shell 路径互补,适合需要精确字节控制或无 shell 语义的场景)。各端点独立校验 file_op_hash——凭证的 {op, path} 必须与请求一致,否则 401(防重放:read 凭证不能用于读写别的路径)。
| 端点 | 请求体 | 响应 |
|---|---|---|
POST /v1/file/read |
{path, offset?, limit?} |
{content_b64, size, offset, returned, truncated}(content_b64 为 base64url-no-pad,支持 offset/limit 分页) |
POST /v1/file/write |
{path, content_b64} |
{bytes_written}(覆盖写) |
POST /v1/file/list |
{path?}(默认 .) |
{entries:[{name, kind, size}]}(按名排序,kind 为 file/dir/other) |
POST /v1/file/stat |
{path} |
{kind, size, modified_unix} |
path为 base 内相对路径;词法预检查 + canonicalize + starts_with(base) 三重校验拒绝路径逃逸(..不得使深度为负,且create_dir_all前先拒,防越界副作用);符号链接覆盖由 canonicalize 兜底。- 单次读写受
SANDBOX_MAX_FILE_BYTES(默认 1 MiB)约束,超出 read 标truncated、write 直接 400。文件操作在主进程运行(非 exec 子进程),不受RLIMIT_*约束,故用此上限替代。 - 后端
SandboxClient.file_read/write/list/stat自动签发file_op_hash凭证并回写tool.started/tool.completed/tool.failed一炁事件(argv 记[file:<op>, <path>]供审计 input_summary)。 - 无
delete端点(设计上不开放删除,防误删;需清理时由运维直接操作 base 目录)。
配置(环境变量)
| 变量 | 默认 | 说明 |
|---|---|---|
SANDBOX_HOST |
0.0.0.0 |
监听地址 |
SANDBOX_PORT |
8090 |
监听端口 |
SANDBOX_BASE_WORKDIR |
../sandbox-work |
沙箱工作根目录(相对二进制所在目录) |
SANDBOX_LOG_DIR |
../logs |
tracing 文件日志目录(相对二进制所在目录,与后端对齐到项目根 logs/);容器内为 /app/logs |
SANDBOX_MAX_TIMEOUT_MS |
30000 |
单次执行全局超时上限(ms) |
SANDBOX_MAX_CONCURRENT |
8 |
最大并发执行数 |
SANDBOX_MAX_OUTPUT_BYTES |
1048576 |
单流最大捕获字节数(1 MiB) |
SANDBOX_MAX_FILE_BYTES |
1048576 |
单次文件读写最大字节数(1 MiB);文件 IO 在主进程,不受 RLIMIT_* 约束,故用此上限 |
SANDBOX_ALLOWED_ENV |
PATH,HOME,TZ,LANG |
默认注入子进程的环境变量白名单(逗号分隔) |
SANDBOX_AUTH_ENABLED |
true |
是否校验司命授权凭证;生产必须为 true |
SANDBOX_ISOLATION_DEFAULT |
process |
凭证未下发 isolation_mode 时的兜底隔离模式(process/container;P0 期间一律按 process) |
SANDBOX_AUTH_SECRET |
— | 授权 HMAC 共享密钥(与后端客户端一致),AUTH_ENABLED=true 时必填 |
SANDBOX_AUTH_TTL_SECONDS |
30 |
授权凭证有效期(秒) |
SANDBOX_AUTH_SECRET_PREVIOUS |
—(空) | 旧 secret(逗号分隔),凭证轮换并存窗口;verify 时与主 secret 一并尝试 |
SANDBOX_RLIMIT_FSIZE_BYTES |
67108864 |
单文件写入上限(64 MiB,普通工具默认) |
SANDBOX_RLIMIT_AS_BYTES |
536870912 |
虚拟内存上限(512 MiB;仅 Linux 生效——macOS 的 RLIMIT_AS 实为 RLIMIT_RSS 别名,不真正限制虚拟地址空间,且 server 自身 AS 常超此值会使 setrlimit 返回 EINVAL 导致 spawn 失败,故 macOS 跳过) |
SANDBOX_RLIMIT_CPU_SECS |
max_timeout 秒 |
CPU 秒数上限(对齐全局超时) |
SANDBOX_RLIMIT_NPROC |
64 |
子进程数上限(防 fork bomb;root 运行时自动跳过) |
SANDBOX_RLIMIT_NOFILE |
256 |
打开文件数上限 |
SANDBOX_RLIMIT_*_MAX |
= 对应 *_BYTES/SECS |
可选,分级场景的全局硬天花板;未设则不允许上探(默认部署等价单一限额) |
SANDBOX_RLIMIT_DISABLE |
—(空) | 显式禁用维度(逗号分隔,如 nproc) |
RUST_LOG |
info |
tracing 日志级别;日志同时写 stdout(彩色)与 <SANDBOX_LOG_DIR>/sandbox.log(按日轮转,当天文件名 sandbox.log.YYYY-MM-DD) |
后端连接沙箱用
.env中的RUST_SANDBOX_URL(例如http://hunjing-sandbox:8090);本服务的自身配置用上面的SANDBOX_*。
本地开发
cd sandbox
cargo run # 监听 0.0.0.0:8090
# 或指定配置
SANDBOX_PORT=8090 SANDBOX_BASE_WORKDIR=./tmp-work cargo run
# 手动测试
curl -s http://127.0.0.1:8090/healthz
curl -s -X POST http://127.0.0.1:8090/v1/exec \
-H 'Content-Type: application/json' \
-d '{"argv":["python3","-c","print(6*7)"]}'
# 验证超时清理
curl -s -X POST http://127.0.0.1:8090/v1/exec \
-H 'Content-Type: application/json' \
-d '{"argv":["sh","-c","sleep 30"],"timeout_ms":300}'
# 验证工作目录隔离与逃逸拒绝
curl -s -X POST http://127.0.0.1:8090/v1/exec \
-H 'Content-Type: application/json' \
-d '{"argv":["sh","-c","pwd"],"workdir":"../../etc"}'
cargo test # 执行内核单元测试(echo / 超时 / 截断 / 路径逃逸 / stdin+env)
cargo build --release # 产出 release 二进制
Docker
镜像构建见 docker/sandbox/Dockerfile,多阶段(rust 构建 → debian-slim 运行),监听 8090。通过 docker-compose.app.yml 的 --profile sandbox 启用。
容器级隔离:sandbox 仅接入 internal 网络 hunjing-sandbox-net(无公网出站,后端经该网络以服务名 hunjing-sandbox 访问),并受 deploy.resources.limits(memory / cpus / pids)与 /tmp tmpfs 约束。网络拓扑与资源定义见 docker/docker-compose.app.yml。容器日志既可经 docker compose logs hunjing-sandbox(stdout)查看,也持久化到挂载的项目根 logs/sandbox.log.YYYY-MM-DD(与后端 app.log 同目录,便于统一收集)。
docker compose -f docker/docker-compose.infra.yml up -d # 先建网络(如未建)
docker compose -f docker/docker-compose.app.yml --profile sandbox up -d --build hunjing-sandbox
docker compose -f docker/docker-compose.app.yml logs -f hunjing-sandbox
后续加固(按需,非当前阶段)
- 自定义 seccomp 白名单(当前用 Docker 默认 profile,已阻止约 50 个危险系统调用;自定义白名单维护成本高且易误伤正常命令)。
- workspace 容量配额与按 run 的自动 GC(当前已实现按 run 隔离 + 启动清理
.tmp*残留)。 - 更强隔离形态:nsjail / gVisor / firecracker microVM(远期)。
IoT / 边缘设备部署
乾坤沙箱可在 IoT 硬件上以进程模式运行(无需 Docker),单静态二进制即可部署。
交叉编译
# ARM64 静态编译(零运行时依赖)
rustup target add aarch64-unknown-linux-musl
cargo build --release --target aarch64-unknown-linux-musl
# 产物大小约 5-8 MB
ls -lh target/aarch64-unknown-linux-musl/release/hunjing-sandbox
# 部署
scp target/aarch64-unknown-linux-musl/release/hunjing-sandbox \
root@device:/usr/local/bin/
资源占用
| 模式 | RSS | 磁盘 |
|---|---|---|
| 进程模式闲置 | ~12 MB | ~5 MB |
| 进程模式执行中 | ~25 MB | - |
| 容器模式(需 Docker) | ~200 MB+ | 镜像额外 ~100 MB |
启动(进程模式)
ssh root@device \
'SANDBOX_DOCKER_ENABLED=false \
SANDBOX_PORT=8090 \
RUST_LOG=info \
/usr/local/bin/hunjing-sandbox'
systemd 托管
[Unit]
Description=hunjing-sandbox (乾坤安全沙箱)
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/hunjing-sandbox
Environment=SANDBOX_DOCKER_ENABLED=false
Environment=SANDBOX_PORT=8090
Environment=SANDBOX_BASE_WORKDIR=/var/lib/hunjing-sandbox
Environment=SANDBOX_LOG_DIR=/var/log/hunjing-sandbox
Environment=RUST_LOG=info
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
硬件兼容
| 硬件 | 架构 | 进程模式 | 容器模式 |
|---|---|---|---|
| Raspberry Pi 4/5 | ARM64 | ✅ | ✅(需 4GB+) |
| Jetson Orin | ARM64 | ✅ | ✅ |
| RK3588 / 全志 H6 | ARM64 | ✅ | ⚠️(需 4GB+) |
| x86 工控机 / NUC | x86-64 | ✅ | ✅ |
| OpenWRT 路由器 | MIPS/ARM | ⚠️(需确认内核支持) | ❌ |
| ESP32 / MCU | - | ❌ | ❌ |