From 142efb070fb0ef52f2dffd3e7b7595723943e79a Mon Sep 17 00:00:00 2001 From: wunianxiao Date: Mon, 22 Jun 2026 17:11:05 +0800 Subject: [PATCH] =?UTF-8?q?refactor(sandbox):=20=E9=95=BF=E6=9C=9F?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=20+=20docker=20exec=20=E6=9B=BF=E4=BB=A3?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E6=80=A7=20docker=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 容器策略从"每次 docker run 一次性容器"改为"长期容器 + docker exec": - 统一产物目录 tools/bin/:build.sh 和 tool_manager 编译后复制产物到 此目录,registry 解析路径优先用 tools/bin/。 - 长期容器 ias-sandbox:daemon 启动时 ensure(已运行则复用), tools/bin 只读挂载到 /tools,无网络/只读根/cap-drop ALL/资源限制。 - docker exec 调用:工具执行改为 docker exec -i ias-sandbox timeout -k 5 /tools/,省去容器创建开销。 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 测试通过。 --- .gitignore | 1 + docs/SECURITY_BOUNDARIES.md | 45 ++++++++-- src/daemon.rs | 8 ++ src/tools/executor.rs | 68 ++------------- src/tools/mod.rs | 1 + src/tools/registry.rs | 27 +++--- src/tools/sandbox.rs | 147 +++++++++++++++++++++++++++++++++ tools/build.sh | 9 +- tools/tool_manager/src/main.rs | 10 +++ 9 files changed, 232 insertions(+), 84 deletions(-) create mode 100644 src/tools/sandbox.rs diff --git a/.gitignore b/.gitignore index 641289e..ae839a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Rust 构建产物 /target tools/*/target/ +tools/bin/ dist/ # 运行时数据(状态文件、缓存、聊天记录本地副本等) diff --git a/docs/SECURITY_BOUNDARIES.md b/docs/SECURITY_BOUNDARIES.md index db7ce2c..c5e47b4 100644 --- a/docs/SECURITY_BOUNDARIES.md +++ b/docs/SECURITY_BOUNDARIES.md @@ -31,7 +31,7 @@ if spec.is_high_risk() && !approved { **修复的漏洞**:原实现审批只在 `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 编译 | ✅ builder 容器 | cargo 执行任意 build script/proc macro | +### 统一产物目录 + +所有工具二进制统一收集到 `tools/bin/`(`build.sh` 编译后复制,`tool_manager` 编译后也复制)。 +registry 解析路径时优先用 `tools/bin/`。 + +### 长期容器模式 + +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 /tools/ --command +``` + +- 容器已运行则复用,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) | +| `ias-stdio-runner` | tool_manager 产出的不受信任工具(长期容器) | none | 只读根 + tmpfs /tmp | +| `ias-tool-builder` | tool_manager 编译 Rust 工具(一次性容器) | none | 可写 /work(bind mount) | 构建:`docker/build-images.sh` @@ -83,11 +112,12 @@ sandbox: ### 启用沙箱 ```bash -export IAS_DOCKER_SANDBOX=1 # 工具执行走一次性容器 +export IAS_DOCKER_SANDBOX=1 # 工具执行走长期容器 docker exec export IAS_DOCKER_BUILDER=1 # tool_manager 编译走 builder 容器 ``` 未设置时退回本地执行(仍保留 env_clear + 白名单注入)。 +daemon 启动时自动 ensure `ias-sandbox` 容器,失败则回退本地执行。 ### builder 容器特殊处理 @@ -133,9 +163,10 @@ enum ToolRunOutcome { |------|------| | tool_manager update 自身 | ❌ 拒绝(保留名) | | create_tool risk_level=low | spec 强制 high | -| tool_manager 产出工具 spec | 自动含 `sandbox: profile: stdio` | -| datetime 本地执行 | +08:00(宿主时区,默认不套容器) | -| tool_manager 产出工具 Docker 执行 | 容器内运行成功 | +| 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 | diff --git a/src/daemon.rs b/src/daemon.rs index 6ed1aa5..9293b3d 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -112,6 +112,14 @@ pub async fn run(database: &Arc, memory_store: &Arc) { database.pool().clone(), )))); 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 model = std::env::var("DEEPSEEK_MODEL").unwrap_or_else(|_| "deepseek-v4-flash".to_string()); diff --git a/src/tools/executor.rs b/src/tools/executor.rs index 55373ee..06361f5 100644 --- a/src/tools/executor.rs +++ b/src/tools/executor.rs @@ -145,11 +145,12 @@ pub async fn execute_loop( } // ── spawn ── - // 执行隔离边界:启用沙箱时按 sandbox.profile 选择一次性 Docker 容器, - // 否则本地 spawn(env_clear + 白名单注入) - let use_docker = sandbox_enabled() && spec.sandbox.profile != "local"; + // 执行隔离边界:启用沙箱且工具 profile 非 local 时,通过 docker exec + // 在长期容器内执行;否则本地 spawn(env_clear + 白名单注入) + let use_docker = + crate::tools::sandbox::enabled() && spec.sandbox.profile != "local"; let mut cmd = if use_docker { - build_docker_cmd(spec, binary_path) + crate::tools::sandbox::build_exec_cmd(spec) } else { 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 白名单注入 fn build_local_cmd(spec: &ToolSpec, binary_path: &str) -> Command { 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 } - -/// 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 -} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 2ea5781..ecaf58b 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -6,4 +6,5 @@ pub mod approval; pub mod executor; pub mod parser; pub mod registry; +pub mod sandbox; pub mod spec; diff --git a/src/tools/registry.rs b/src/tools/registry.rs index 4d69a61..d53cf4c 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -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); if path.is_absolute() { return path.to_path_buf(); @@ -89,15 +89,22 @@ impl Registry { } for mut spec in result.specs { - // 解析二进制路径 - let binary = match &spec.path { - Some(p) => { - let p = p.trim_start_matches('/'); - path.join(p) - } - None => { - // 默认: {tool_dir}/target/release/{tool} - path.join("target/release").join(&spec.tool) + // 优先使用统一产物目录 tools/bin/,不存在则 fallback 到工具子目录 + let binary = { + let unified = root.join("bin").join(&spec.tool); + if unified.exists() { + unified + } else { + match &spec.path { + Some(p) => { + let p = p.trim_start_matches('/'); + path.join(p) + } + None => { + // 默认: {tool_dir}/target/release/{tool} + path.join("target/release").join(&spec.tool) + } + } } }; diff --git a/src/tools/sandbox.rs b/src/tools/sandbox.rs new file mode 100644 index 0000000..15c16f9 --- /dev/null +++ b/src/tools/sandbox.rs @@ -0,0 +1,147 @@ +//! # 沙箱容器管理 +//! +//! 长期容器模式:daemon 启动时创建 `ias-sandbox` 容器(无网络、只读根、cap-drop ALL), +//! 统一产物目录 `tools/bin/` 只读挂载到容器 `/tools`。工具调用通过 `docker exec` +//! 在容器内执行,相比每次 `docker run` 省去容器创建/销毁开销。 +//! +//! ## 超时与清理 +//! `docker exec` 内用 `timeout -k 5 ` 包裹工具进程:超时发 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` 包裹:超时 SIGTERM,5 秒后 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 :超时 SIGTERM,5s 后 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 { + let resolved = crate::tools::registry::resolve_dir(dir); + resolved + .canonicalize() + .map_err(|e| format!("无法定位 tools 目录 {}: {e}", resolved.display())) +} diff --git a/tools/build.sh b/tools/build.sh index f02357a..e9db94c 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -1,17 +1,18 @@ #!/bin/bash -# 构建所有工具 +# 构建所有工具,产物统一收集到 bin/ 供长期沙箱容器挂载 set -e cd "$(dirname "$0")" TOOLS=(datetime weather amap web_search fetch_page memories tool_manager) +mkdir -p bin + echo "=== 构建工具 ===" for tool in "${TOOLS[@]}"; do echo " $tool..." (cd "$tool" && cargo build --release --quiet) + cp "$tool/target/release/$tool" "bin/$tool" done echo "=== 完成 ===" -for tool in "${TOOLS[@]}"; do - ls -lh "$tool/target/release/$tool" 2>/dev/null -done +ls -lh bin/* diff --git a/tools/tool_manager/src/main.rs b/tools/tool_manager/src/main.rs index 85feaab..dbc98cb 100644 --- a/tools/tool_manager/src/main.rs +++ b/tools/tool_manager/src/main.rs @@ -175,6 +175,16 @@ fn build_tool(tool_dir: &Path, tool_name: &str) -> Result<(), String> { if !binary.exists() { 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(()) }