# 乾坤 · 魂镜安全沙箱服务(hunjing-sandbox / Sandbox Engine) > 文化命名「乾坤」(Sandbox Engine),百工枢的隔离执行后端——为器用百工提供一个自成天地、越界即止的封闭执行世界。命名登记见 `docs/contracts/contract-glossary.md` §2.1。 Rust + axum 实现的 HTTP 微服务,为「百工 Tool Runtime」提供**本地隔离的命令执行层**。在系统中的定位: ```mermaid flowchart LR Backend["后端 FastAPI"] -->|POST /v1/exec| Sandbox["hunjing-sandbox (Rust)"] Sandbox --> Child["受限子进程
进程组 / 临时工作目录 / 超时"] Child --> System["shell / python / 外部命令"] Sandbox -->|SIGKILL 进程组| Cleanup["超时或取消时清理子进程"] ``` > 安全边界:沙箱只执行**已授权任务**,不做风险判定(由司命 Safety 负责);调用方应在发起前完成权限与风险审核。设计依据见 `docs/architecture/service-boundaries.md` 与 `docs/architecture/service-boundaries.md`。 ## 司命授权 `/v1/exec` 受 HMAC 授权中间件保护(`/healthz` 不受保护)。调用方(后端百工客户端)需经司命审核后签发短期凭证,沙箱端验签通过才执行。 - 凭证格式:`.`,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。metric `sandbox_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 `。 - 校验失败统一返回 `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/`(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` ```json { "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` 请求体: ```json { "argv": ["python3", "-c", "print(1 + 1)"], "stdin": "可选标准输入", "timeout_ms": 5000, "workdir": "可选,base 内的相对子目录", "env": { "GREETING": "hi" }, "max_output_bytes": 1048576, "trace_id": "可选,透传用" } ``` 响应体: ```json { "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)。 错误统一格式(见跨语言契约): ```json { "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:, ]` 供审计 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`(按日轮转,当天文件名 `sandbox.log.YYYY-MM-DD`) | > 后端连接沙箱用 `.env` 中的 `RUST_SANDBOX_URL`(例如 `http://hunjing-sandbox:8090`);本服务的自身配置用上面的 `SANDBOX_*`。 ## 本地开发 ```bash 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` 同目录,便于统一收集)。 ```bash 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),单静态二进制即可部署。 ### 交叉编译 ```bash # 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 | ### 启动(进程模式) ```bash ssh root@device \ 'SANDBOX_DOCKER_ENABLED=false \ SANDBOX_PORT=8090 \ RUST_LOG=info \ /usr/local/bin/hunjing-sandbox' ``` ### systemd 托管 ```ini [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 | - | ❌ | ❌ |