refactor(sandbox): 长期容器 + docker exec 替代一次性 docker run

容器策略从"每次 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 测试通过。
This commit is contained in:
2026-06-22 17:11:05 +08:00
parent 092f3b78f2
commit 142efb070f
9 changed files with 232 additions and 84 deletions
+1
View File
@@ -1,6 +1,7 @@
# Rust 构建产物 # Rust 构建产物
/target /target
tools/*/target/ tools/*/target/
tools/bin/
dist/ dist/
# 运行时数据(状态文件、缓存、聊天记录本地副本等) # 运行时数据(状态文件、缓存、聊天记录本地副本等)
+38 -7
View File
@@ -31,7 +31,7 @@ if spec.is_high_risk() && !approved {
**修复的漏洞**:原实现审批只在 `http`/`db` 消息分支触发,`stdio → result` 可绕过。`tool_manager`high risk)直接输出 result 即可零确认执行。 **修复的漏洞**:原实现审批只在 `http`/`db` 消息分支触发,`stdio → result` 可绕过。`tool_manager`high risk)直接输出 result 即可零确认执行。
## 第二层:一次性 Docker 容器(隔离边界) ## 第二层:长期沙箱容器 + docker exec(隔离边界)
### 适用范围(按威胁模型精确化) ### 适用范围(按威胁模型精确化)
@@ -44,12 +44,41 @@ http/db 类工具的子进程是"瘦壳"——危险操作(联网、DB 读写
| tool_manager 产出的工具 | ✅ stdio 容器 | 含 LLM 提供的 rust_code,不受信任 | | tool_manager 产出的工具 | ✅ stdio 容器 | 含 LLM 提供的 rust_code,不受信任 |
| tool_manager 编译 | ✅ builder 容器 | cargo 执行任意 build script/proc macro | | 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-stdio-runner` | tool_manager 产出的不受信任工具(长期容器) | none | 只读根 + tmpfs /tmp |
| `ias-tool-builder` | tool_manager 编译 Rust 工具 | none | 可写 /workbind mount | | `ias-tool-builder` | tool_manager 编译 Rust 工具(一次性容器) | none | 可写 /workbind mount |
构建:`docker/build-images.sh` 构建:`docker/build-images.sh`
@@ -83,11 +112,12 @@ sandbox:
### 启用沙箱 ### 启用沙箱
```bash ```bash
export IAS_DOCKER_SANDBOX=1 # 工具执行走一次性容器 export IAS_DOCKER_SANDBOX=1 # 工具执行走长期容器 docker exec
export IAS_DOCKER_BUILDER=1 # tool_manager 编译走 builder 容器 export IAS_DOCKER_BUILDER=1 # tool_manager 编译走 builder 容器
``` ```
未设置时退回本地执行(仍保留 env_clear + 白名单注入)。 未设置时退回本地执行(仍保留 env_clear + 白名单注入)。
daemon 启动时自动 ensure `ias-sandbox` 容器,失败则回退本地执行。
### builder 容器特殊处理 ### builder 容器特殊处理
@@ -133,9 +163,10 @@ enum ToolRunOutcome {
|------|------| |------|------|
| tool_manager update 自身 | ❌ 拒绝(保留名) | | tool_manager update 自身 | ❌ 拒绝(保留名) |
| create_tool risk_level=low | spec 强制 high | | create_tool risk_level=low | spec 强制 high |
| tool_manager 产出工具 spec | 自动含 `sandbox: profile: stdio` | | tool_manager 产出工具 spec | 自动含 `sandbox: profile: stdio` + 产物复制到 `tools/bin/` |
| datetime 本地执行 | +08:00(宿主时区,默认不套容器) | | datetime 本地执行 | +08:00(宿主时区,开发者工具默认不套容器) |
| tool_manager 产出工具 Docker 执行 | 容器内运行成功 | | tool_manager 产出工具 docker exec | 容器内 hostname=容器ID,证明确实走长期容器 exec |
| 容器常驻 | docker ps 可见 ias-sandboxexec 完无孤儿进程 |
| builder 容器编译 serde_json | ✅ 离线成功 | | builder 容器编译 serde_json | ✅ 离线成功 |
| cargo test | 25 passed | | cargo test | 25 passed |
+8
View File
@@ -112,6 +112,14 @@ pub async fn run(database: &Arc<Database>, memory_store: &Arc<MemoryStore>) {
database.pool().clone(), database.pool().clone(),
)))); ))));
let registry = Arc::new(RegistryManager::load("tools")); let registry = Arc::new(RegistryManager::load("tools"));
// 沙箱容器(如果启用):daemon 启动时创建长期容器,失败则回退本地执行
if crate::tools::sandbox::enabled() {
if let Err(e) = crate::tools::sandbox::ensure_container("tools").await {
tracing::warn!(target: "ias::sandbox", "沙箱容器启动失败,回退本地执行: {e}");
}
}
let system_prompt = std::env::var("WEIXIN_LLM_SYSTEM_PROMPT").unwrap_or_else(|_| String::new()); let system_prompt = std::env::var("WEIXIN_LLM_SYSTEM_PROMPT").unwrap_or_else(|_| String::new());
let model = std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| "deepseek-v4-flash".to_string()); let model = std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| "deepseek-v4-flash".to_string());
+5 -63
View File
@@ -145,11 +145,12 @@ pub async fn execute_loop(
} }
// ── spawn ── // ── spawn ──
// 执行隔离边界:启用沙箱时按 sandbox.profile 选择一次性 Docker 容器, // 执行隔离边界:启用沙箱且工具 profile 非 local 时,通过 docker exec
// 否则本地 spawnenv_clear + 白名单注入) // 在长期容器内执行;否则本地 spawnenv_clear + 白名单注入)
let use_docker = sandbox_enabled() && spec.sandbox.profile != "local"; let use_docker =
crate::tools::sandbox::enabled() && spec.sandbox.profile != "local";
let mut cmd = if use_docker { let mut cmd = if use_docker {
build_docker_cmd(spec, binary_path) crate::tools::sandbox::build_exec_cmd(spec)
} else { } else {
build_local_cmd(spec, binary_path) build_local_cmd(spec, binary_path)
}; };
@@ -347,14 +348,6 @@ pub async fn execute_loop(
} }
} }
/// 全局沙箱开关:`IAS_DOCKER_SANDBOX=1` 启用一次性容器隔离
fn sandbox_enabled() -> bool {
std::env::var("IAS_DOCKER_SANDBOX")
.ok()
.as_deref()
== Some("1")
}
/// 本地执行:env_clear + PATH + spec.env 白名单注入 /// 本地执行:env_clear + PATH + spec.env 白名单注入
fn build_local_cmd(spec: &ToolSpec, binary_path: &str) -> Command { fn build_local_cmd(spec: &ToolSpec, binary_path: &str) -> Command {
let mut cmd = Command::new(std::path::Path::new(binary_path)); let mut cmd = Command::new(std::path::Path::new(binary_path));
@@ -377,54 +370,3 @@ fn build_local_cmd(spec: &ToolSpec, binary_path: &str) -> Command {
} }
cmd cmd
} }
/// Docker 隔离执行:一次性容器,默认无网络、只读根、cap-drop ALL、
/// no-new-privileges,仅挂载工具二进制(ro+ tmpfs /tmp。
/// 供 tool_manager 产出的不受信任工具使用(profile: stdio)。
fn build_docker_cmd(spec: &ToolSpec, binary_path: &str) -> Command {
let image = match spec.sandbox.profile.as_str() {
"builder" => "ias-tool-builder",
_ => "ias-stdio-runner",
};
let mut cmd = Command::new("docker");
cmd.arg("run")
.arg("--rm")
.arg("--network")
.arg(if spec.sandbox.network == "default" {
"default"
} else {
"none"
})
.arg("--memory")
.arg(&spec.sandbox.memory)
.arg("--cpus")
.arg(&spec.sandbox.cpus)
.arg("--pids-limit")
.arg(spec.sandbox.pids_limit.to_string())
.arg("--cap-drop")
.arg("ALL")
.arg("--security-opt")
.arg("no-new-privileges");
if spec.sandbox.read_only {
cmd.arg("--read-only");
}
// 工具二进制只读挂载到 /tool
cmd.arg("--mount")
.arg(format!("type=bind,source={binary_path},target=/tool,readonly"));
// 可写临时目录
cmd.arg("--mount")
.arg("type=tmpfs,target=/tmp");
// env:容器内固定 PATH + spec 声明的白名单
cmd.arg("--env")
.arg("PATH=/usr/local/bin:/usr/bin:/bin");
for key in &spec.env {
if let Ok(v) = std::env::var(key) {
cmd.arg("--env").arg(format!("{key}={v}"));
}
}
cmd.arg(image).arg("/tool");
if let Some(c) = &spec.command {
cmd.arg("--command").arg(c);
}
cmd
}
+1
View File
@@ -6,4 +6,5 @@ pub mod approval;
pub mod executor; pub mod executor;
pub mod parser; pub mod parser;
pub mod registry; pub mod registry;
pub mod sandbox;
pub mod spec; pub mod spec;
+10 -3
View File
@@ -29,7 +29,7 @@ pub struct ParamSummary {
} }
/// 解析工具目录。相对路径基于二进制所在目录。 /// 解析工具目录。相对路径基于二进制所在目录。
fn resolve_dir(dir: &str) -> PathBuf { pub(crate) fn resolve_dir(dir: &str) -> PathBuf {
let path = Path::new(dir); let path = Path::new(dir);
if path.is_absolute() { if path.is_absolute() {
return path.to_path_buf(); return path.to_path_buf();
@@ -89,8 +89,13 @@ impl Registry {
} }
for mut spec in result.specs { for mut spec in result.specs {
// 解析二进制路径 // 优先使用统一产物目录 tools/bin/<tool>,不存在则 fallback 到工具子目录
let binary = match &spec.path { let binary = {
let unified = root.join("bin").join(&spec.tool);
if unified.exists() {
unified
} else {
match &spec.path {
Some(p) => { Some(p) => {
let p = p.trim_start_matches('/'); let p = p.trim_start_matches('/');
path.join(p) path.join(p)
@@ -99,6 +104,8 @@ impl Registry {
// 默认: {tool_dir}/target/release/{tool} // 默认: {tool_dir}/target/release/{tool}
path.join("target/release").join(&spec.tool) path.join("target/release").join(&spec.tool)
} }
}
}
}; };
if !binary.exists() { if !binary.exists() {
+147
View File
@@ -0,0 +1,147 @@
//! # 沙箱容器管理
//!
//! 长期容器模式:daemon 启动时创建 `ias-sandbox` 容器(无网络、只读根、cap-drop ALL),
//! 统一产物目录 `tools/bin/` 只读挂载到容器 `/tools`。工具调用通过 `docker exec`
//! 在容器内执行,相比每次 `docker run` 省去容器创建/销毁开销。
//!
//! ## 超时与清理
//! `docker exec` 内用 `timeout -k 5 <secs>` 包裹工具进程:超时发 SIGTERM,
//! 5 秒后仍不退出发 SIGKILL,确保容器内不累积孤儿进程。executor 的 tokio timeout
//! 作为外层保险,kill `docker exec` 客户端释放宿主资源。
use std::path::{Path, PathBuf};
use tokio::process::Command;
use crate::tools::spec::ToolSpec;
/// 长期沙箱容器名
pub const CONTAINER_NAME: &str = "ias-sandbox";
/// 容器内产物挂载点
const MOUNT_TARGET: &str = "/tools";
/// 全局沙箱开关:`IAS_DOCKER_SANDBOX=1` 启用
pub fn enabled() -> bool {
std::env::var("IAS_DOCKER_SANDBOX")
.ok()
.as_deref()
== Some("1")
}
/// 确保 ias-sandbox 长期容器在运行。daemon 启动时调用。
///
/// - 容器已运行:直接复用
/// - 容器不存在/已停止:清理后重新创建
///
/// 失败返回 Err,调用方应回退本地执行。
pub async fn ensure_container(dir: &str) -> Result<(), String> {
let root = locate_tools_root(dir)?;
let bin_dir = root.join("bin");
std::fs::create_dir_all(&bin_dir).map_err(|e| format!("创建产物目录失败: {e}"))?;
// 检查是否已在运行
let running = Command::new("docker")
.args(["inspect", "-f", "{{.State.Running}}", CONTAINER_NAME])
.output()
.await
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true")
.unwrap_or(false);
if running {
tracing::info!(target: "ias::sandbox", "沙箱容器已运行,复用");
return Ok(());
}
// 清理旧的已停止容器
let _ = Command::new("docker")
.args(["rm", "-f", CONTAINER_NAME])
.output()
.await;
// 启动长期容器
let output = Command::new("docker")
.arg("run")
.arg("-d")
.arg("--rm")
.arg("--name")
.arg(CONTAINER_NAME)
.arg("--network")
.arg("none")
.arg("--cap-drop")
.arg("ALL")
.arg("--security-opt")
.arg("no-new-privileges")
.arg("--read-only")
.arg("--memory")
.arg("512m")
.arg("--cpus")
.arg("1")
.arg("--pids-limit")
.arg("128")
.arg("--mount")
.arg(format!(
"type=bind,source={},target={MOUNT_TARGET},readonly",
bin_dir.display()
))
.arg("--mount")
.arg("type=tmpfs,target=/tmp")
.arg("ias-stdio-runner")
.args(["sleep", "infinity"])
.output()
.await
.map_err(|e| format!("启动沙箱容器失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("沙箱容器启动失败: {}", stderr.trim()));
}
tracing::info!(
target: "ias::sandbox",
"沙箱容器已启动 ({} → {})",
bin_dir.display(),
MOUNT_TARGET
);
Ok(())
}
/// 构建 `docker exec` 命令,在长期容器内执行工具。
///
/// 容器内路径 = MOUNT_TARGET + 二进制文件名。
/// 用 `timeout -k 5` 包裹:超时 SIGTERM5 秒后 SIGKILL,避免孤儿进程。
/// env 按 spec 白名单注入(`-e` 必须在容器名前)。
pub fn build_exec_cmd(spec: &ToolSpec) -> Command {
let binary_name = Path::new(spec.path.as_deref().unwrap_or(&spec.tool))
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| spec.tool.clone());
let container_path = format!("{MOUNT_TARGET}/{binary_name}");
let mut cmd = Command::new("docker");
cmd.arg("exec").arg("-i");
// env 白名单注入(OPTIONS,必须在容器名前)
for key in &spec.env {
if let Ok(v) = std::env::var(key) {
cmd.arg("-e").arg(format!("{key}={v}"));
}
}
cmd.arg(CONTAINER_NAME);
// timeout -k 5 <secs>:超时 SIGTERM5s 后 SIGKILL
cmd.arg("timeout").arg("-k").arg("5").arg(spec.timeout_secs.to_string());
cmd.arg(container_path);
if let Some(c) = &spec.command {
cmd.arg("--command").arg(c);
}
cmd
}
/// 复用 registry 的路径解析逻辑定位 tools 根目录并转绝对路径
fn locate_tools_root(dir: &str) -> Result<PathBuf, String> {
let resolved = crate::tools::registry::resolve_dir(dir);
resolved
.canonicalize()
.map_err(|e| format!("无法定位 tools 目录 {}: {e}", resolved.display()))
}
+5 -4
View File
@@ -1,17 +1,18 @@
#!/bin/bash #!/bin/bash
# 构建所有工具 # 构建所有工具,产物统一收集到 bin/ 供长期沙箱容器挂载
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
TOOLS=(datetime weather amap web_search fetch_page memories tool_manager) TOOLS=(datetime weather amap web_search fetch_page memories tool_manager)
mkdir -p bin
echo "=== 构建工具 ===" echo "=== 构建工具 ==="
for tool in "${TOOLS[@]}"; do for tool in "${TOOLS[@]}"; do
echo " $tool..." echo " $tool..."
(cd "$tool" && cargo build --release --quiet) (cd "$tool" && cargo build --release --quiet)
cp "$tool/target/release/$tool" "bin/$tool"
done done
echo "=== 完成 ===" echo "=== 完成 ==="
for tool in "${TOOLS[@]}"; do ls -lh bin/*
ls -lh "$tool/target/release/$tool" 2>/dev/null
done
+10
View File
@@ -175,6 +175,16 @@ fn build_tool(tool_dir: &Path, tool_name: &str) -> Result<(), String> {
if !binary.exists() { if !binary.exists() {
return Err(format!("构建后未找到二进制: {}", binary.display())); return Err(format!("构建后未找到二进制: {}", binary.display()));
} }
// 复制到统一产物目录 tools/bin/,供长期沙箱容器挂载使用
let tools_root = tool_dir
.parent()
.ok_or_else(|| "无法定位 tools 根目录".to_string())?;
let bin_dir = tools_root.join("bin");
fs::create_dir_all(&bin_dir).map_err(|e| format!("创建产物目录失败: {e}"))?;
fs::copy(&binary, bin_dir.join(tool_name))
.map_err(|e| format!("复制产物到 bin 失败: {e}"))?;
Ok(()) Ok(())
} }