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:
@@ -1,6 +1,7 @@
|
||||
# Rust 构建产物
|
||||
/target
|
||||
tools/*/target/
|
||||
tools/bin/
|
||||
dist/
|
||||
|
||||
# 运行时数据(状态文件、缓存、聊天记录本地副本等)
|
||||
|
||||
@@ -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/<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) |
|
||||
| `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 |
|
||||
|
||||
|
||||
@@ -112,6 +112,14 @@ pub async fn run(database: &Arc<Database>, memory_store: &Arc<MemoryStore>) {
|
||||
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());
|
||||
|
||||
|
||||
+5
-63
@@ -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
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ pub mod approval;
|
||||
pub mod executor;
|
||||
pub mod parser;
|
||||
pub mod registry;
|
||||
pub mod sandbox;
|
||||
pub mod spec;
|
||||
|
||||
+17
-10
@@ -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/<tool>,不存在则 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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` 包裹:超时 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 <secs>:超时 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<PathBuf, String> {
|
||||
let resolved = crate::tools::registry::resolve_dir(dir);
|
||||
resolved
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("无法定位 tools 目录 {}: {e}", resolved.display()))
|
||||
}
|
||||
+5
-4
@@ -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/*
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user