容器策略从"每次 docker run 一次性容器"改为"长期容器 + docker exec": - 统一产物目录 tools/bin/:build.sh 和 tool_manager 编译后复制产物到 此目录,registry 解析路径优先用 tools/bin/<tool>。 - 长期容器 ias-sandbox:daemon 启动时 ensure(已运行则复用), tools/bin 只读挂载到 /tools,无网络/只读根/cap-drop ALL/资源限制。 - docker exec 调用:工具执行改为 docker exec -i ias-sandbox timeout -k 5 <secs> /tools/<name>,省去容器创建开销。 timeout -k 5 确保超时 SIGTERM→SIGKILL,无孤儿进程。 - 新增 src/tools/sandbox.rs:ensure_container + build_exec_cmd。 - executor 删除 build_docker_cmd/sandbox_enabled,docker 分支改用 sandbox::build_exec_cmd。 - env 按 spec 白名单 -e 注入(容器启动时仅有 PATH)。 验证:本地执行 hostname=debian,docker exec hostname=容器ID, 证明确实走长期容器 exec;25 测试通过。
7.3 KiB
两层安全边界设计
审批决定"能不能做",Docker 限制"做坏了能坏到哪里",审计回答"谁让系统新增或修改了什么能力"。
架构
LLM 调用工具
→ registry.dispatch(name, params, user_id, approved)
→ load spec + validate params
→ 【授权边界】if high_risk && !approved: 统一审批(不依赖消息类型)
→ 【隔离边界】按 sandbox.profile 选择执行方式:
- local : 本地 spawn(env_clear + 白名单注入)
- stdio : docker run ias-stdio-runner (无网络、只读根)
- network : docker run ias-network-runner(联网、资源限制)
- builder : docker run ias-tool-builder (离线编译,仅 tool_manager)
→ stdin 传 envelope → stdout 读 JSON → 超时 kill → 容器销毁
第一层:统一工具级审批(授权边界)
位置:src/tools/executor.rs execute_loop() 入口
高风险工具在 spawn 之前 必须获得用户确认,与工具后续输出的消息类型(result/http/db)无关。
if spec.is_high_risk() && !approved {
// 发送确认码 → 等待用户回复 → Approved/Rejected/Expired
}
修复的漏洞:原实现审批只在 http/db 消息分支触发,stdio → result 可绕过。tool_manager(high risk)直接输出 result 即可零确认执行。
第二层:长期沙箱容器 + docker exec(隔离边界)
适用范围(按威胁模型精确化)
http/db 类工具的子进程是"瘦壳"——危险操作(联网、DB 读写)全在受信任的主进程里, 给它们套容器是纯开销,没有隔离收益。因此:
| 场景 | 是否容器隔离 | 原因 |
|---|---|---|
| 开发者预写工具(weather/amap/...) | ❌ 本地执行 | 子进程纯计算,危险操作在主进程 |
| tool_manager 产出的工具 | ✅ stdio 容器 | 含 LLM 提供的 rust_code,不受信任 |
| tool_manager 编译 | ✅ builder 容器 | cargo 执行任意 build script/proc macro |
统一产物目录
所有工具二进制统一收集到 tools/bin/(build.sh 编译后复制,tool_manager 编译后也复制)。
registry 解析路径时优先用 tools/bin/<tool>。
长期容器模式
daemon 启动时创建常驻容器 ias-sandbox,tools/bin/ 只读挂载到 /tools:
docker run -d --rm --name ias-sandbox \
--network none --cap-drop ALL --security-opt no-new-privileges \
--read-only --memory 512m --cpus 1 --pids-limit 128 \
--mount type=bind,source=tools/bin,target=/tools,readonly \
--mount type=tmpfs,target=/tmp \
ias-stdio-runner sleep infinity
工具调用通过 docker exec 在容器内执行(省去每次 docker run 的创建开销):
docker exec -i -e KEY=VAL ias-sandbox timeout -k 5 <secs> /tools/<name> --command <cmd>
- 容器已运行则复用,daemon 重启不重建
timeout -k 5超时 SIGTERM、5s 后 SIGKILL,避免孤儿进程- env 按 spec 白名单
-e注入(容器启动时仅有 PATH) - 新工具由 tool_manager 编译后复制到
tools/bin/,bind mount 自动可见,无需重启容器
两类基础镜像
| 镜像 | 用途 | 网络 | 文件系统 |
|---|---|---|---|
ias-stdio-runner |
tool_manager 产出的不受信任工具(长期容器) | none | 只读根 + tmpfs /tmp |
ias-tool-builder |
tool_manager 编译 Rust 工具(一次性容器) | none | 可写 /work(bind mount) |
构建:docker/build-images.sh
容器安全基线
所有容器统一:
--rm一次性,用完销毁--cap-drop ALL丢弃所有 Linux capability--security-opt no-new-privileges禁止提权--memory/--cpus/--pids-limit资源限制- 只挂载工具二进制(readonly)+ tmpfs /tmp
- 不挂载项目根目录、不挂载 docker.sock
- env 按白名单注入(
env_clear+ spec.env)
沙箱配置(spec)
sandbox:
profile: stdio # local | stdio | builder
network: none # none | default
memory: 256m
cpus: "0.5"
pids_limit: 64
read_only: true
默认 profile: local(不启用容器)。开发者预写工具保持默认。
tool_manager 产出的工具自动写入 sandbox: profile: stdio(见 tool_manager spec_yaml),
不受信任代码默认进容器。
启用沙箱
export IAS_DOCKER_SANDBOX=1 # 工具执行走长期容器 docker exec
export IAS_DOCKER_BUILDER=1 # tool_manager 编译走 builder 容器
未设置时退回本地执行(仍保留 env_clear + 白名单注入)。
daemon 启动时自动 ensure ias-sandbox 容器,失败则回退本地执行。
builder 容器特殊处理
--cap-drop ALL 丢掉 CAP_DAC_OVERRIDE,root 无法写 bind mount 的非 root 目录。
解法:builder 容器以宿主 uid:gid 运行(--user),自然能写自己的目录。
镜像内 CARGO_HOME=/tmp/cargo 预缓存 serde_json 并 chmod 777,任意 uid 可用。
编译加 --offline,确保 --network none 下不尝试联网。
tool_manager 加固
| 措施 | 实现 |
|---|---|
| 禁止自改 | RESERVED_NAMES 包含 tool_manager,禁止 create/update/build |
| 强制 high risk | spec_yaml 忽略调用方 risk_level,一律写 high |
| 审计日志 | 每次调用追加 tools/.tool_audit.log(ts/uid/action/tool/result) |
| 构建超时 | timeout 180s 包裹 cargo build |
| 失败清理 | 构建失败删除残留旧二进制,避免 reload 后调用旧版本"假成功" |
| 离线编译 | IAS_DOCKER_BUILDER=1 时在 builder 容器内 --offline 编译 |
结构化结果
executor 返回 ToolRunOutcome 枚举替代裸字符串:
enum ToolRunOutcome {
Ok(String),
Err { kind: ToolErrorKind, message: String },
}
daemon 据此判断是否 reload(tool_manager 成功后重载注册表),
替代原 is_tool_error() 的中文前缀字符串匹配。
审批并发修复
ApprovalManager::clean_expired() 两阶段加锁:
收集过期 code_hash → 更新 DB → 重新加锁按 code_hash 定位删除。
不依赖快照 index,避免并发回复导致 index 错位误删未过期审批。
验证
| 场景 | 结果 |
|---|---|
| tool_manager update 自身 | ❌ 拒绝(保留名) |
| create_tool risk_level=low | spec 强制 high |
| tool_manager 产出工具 spec | 自动含 sandbox: profile: stdio + 产物复制到 tools/bin/ |
| datetime 本地执行 | +08:00(宿主时区,开发者工具默认不套容器) |
| tool_manager 产出工具 docker exec | 容器内 hostname=容器ID,证明确实走长期容器 exec |
| 容器常驻 | docker ps 可见 ias-sandbox,exec 完无孤儿进程 |
| builder 容器编译 serde_json | ✅ 离线成功 |
| cargo test | 25 passed |
实施状态
| # | 步骤 | 状态 |
|---|---|---|
| 1 | 统一工具级审批 | ✅ |
| 2 | 禁止 tool_manager 自改 | ✅ |
| 3 | 强制生成工具 high risk | ✅ |
| 4 | env_clear 环境白名单 | ✅ |
| 5 | 修 clean_expired 并发 | ✅ |
| 6 | 三类 Docker 镜像 + sandbox spec | ✅ |
| 7 | stdio 工具迁移到一次性容器 | ✅ |
| 8 | network 工具迁移 | ➖ 不需要:工具走宿主代理联网,子进程纯计算,套容器无隔离收益 |
| 9 | tool_manager build 迁移到 builder 容器 | ✅ |
| 10 | 结构化结果替换 is_tool_error | ✅ |