From bf231352fb54ae7c52eb7e5a96a5efb31a94aa9d Mon Sep 17 00:00:00 2001 From: wunianxiao Date: Mon, 22 Jun 2026 10:37:42 +0800 Subject: [PATCH 1/9] =?UTF-8?q?security(tool=5Fmanager):=20=E7=A6=81?= =?UTF-8?q?=E6=AD=A2=E8=87=AA=E6=94=B9=E4=BF=9D=E7=95=99=E5=90=8D=20+=20?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=20high=20risk=20+=20=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E6=97=A5=E5=BF=97=20+=20=E6=9E=84=E5=BB=BA=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-2: validate_tool_name 将 tool_manager 等列为保留名,禁止 create/update/build 作用于自身,杜绝持久化后门。 P0-3: spec_yaml 忽略调用方传入的 risk_level,由 tool_manager 产出的工具一律 high risk,避免创建低风险后门绕过审批。 审计: 每次调用追加记录到 tools/.tool_audit.log (timestamp/user_id/action/tool_name/ok|err/detail)。 构建: 用 timeout 限制 180s,失败时删除残留旧二进制, 避免 reload 后调用旧版本造成"假成功"。 --- .../tool_manager/specs/tool_manager.tool.yaml | 4 +- tools/tool_manager/src/main.rs | 98 +++++++++++++++---- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/tools/tool_manager/specs/tool_manager.tool.yaml b/tools/tool_manager/specs/tool_manager.tool.yaml index 00cda49..ba7429f 100644 --- a/tools/tool_manager/specs/tool_manager.tool.yaml +++ b/tools/tool_manager/specs/tool_manager.tool.yaml @@ -4,7 +4,7 @@ type: stdio tool: tool_manager path: /target/release/tool_manager risk_level: high -timeout_secs: 120 +timeout_secs: 300 params: - name: action required: true @@ -23,7 +23,7 @@ params: desc: 参数列表数组,每项包含 name、required、desc - name: risk_level required: false - desc: 新工具风险级别,low 或 high,默认 low + desc: 已弃用,tool_manager 创建/更新的工具一律强制 high risk,传入值将被忽略 - name: timeout_secs required: false desc: 新工具超时时间秒数,默认 30 diff --git a/tools/tool_manager/src/main.rs b/tools/tool_manager/src/main.rs index fbe9ac3..bb69e60 100644 --- a/tools/tool_manager/src/main.rs +++ b/tools/tool_manager/src/main.rs @@ -1,10 +1,22 @@ //! tool_manager — 受控创建、修改、构建本地工具 +//! +//! 安全约束: +//! - 不允许操作保留工具名(含 `tool_manager` 自身),防止自改后门 +//! - 由本工具创建/更新的工具一律 `risk_level: high`,忽略调用方传入值 +//! - 所有调用追加写入 `tools/.tool_audit.log` 审计日志 +//! - 构建使用 `timeout` 限制时长,失败时清理残留二进制,避免调用旧版本"假成功" use serde_json::{Value, json}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; +/// 保留工具名 —— 禁止 create/update/build +const RESERVED_NAMES: &[&str] = &["target", "specs", "src", ".", "..", "tool_manager"]; + +/// 构建超时(秒)。需小于 tool_manager 自身的 timeout_secs。 +const BUILD_TIMEOUT_SECS: u64 = 180; + fn main() { let input: Value = match serde_json::from_reader(std::io::stdin()) { Ok(v) => v, @@ -12,13 +24,20 @@ fn main() { }; let params = &input["params"]; + let user_id = input["user_id"].as_str().unwrap_or("unknown"); let action = params["action"].as_str().unwrap_or(""); let tool_name = params["tool_name"].as_str().unwrap_or(""); - let output = match run(action, tool_name, params) { - Ok(msg) => msg, - Err(e) => format!("工具管理失败: {e}"), + let (output, success) = match run(action, tool_name, params) { + Ok(msg) => (msg, true), + Err(e) => (format!("工具管理失败: {e}"), false), }; + + // 审计日志(best effort,不影响主流程) + if let Ok(tools_root) = locate_tools_root() { + audit(&tools_root, user_id, action, tool_name, success, &output); + } + result(&output); } @@ -38,7 +57,7 @@ fn run(action: &str, tool_name: &str, params: &Value) -> Result write_tool_project(&tool_dir, tool_name, params)?; build_tool(&tool_dir, tool_name)?; Ok(format!( - "已创建并构建工具 {tool_name}。可通过 query_capabilities 查看并用 call_capability 调用。" + "已创建并构建工具 {tool_name}(risk_level 强制为 high)。可通过 query_capabilities 查看并用 call_capability 调用。" )) } "update_tool" => { @@ -48,7 +67,7 @@ fn run(action: &str, tool_name: &str, params: &Value) -> Result update_tool_project(&tool_dir, tool_name, params)?; build_tool(&tool_dir, tool_name)?; Ok(format!( - "已修改并构建工具 {tool_name}。注册表重载后即可使用最新版本。" + "已修改并构建工具 {tool_name}(risk_level 强制为 high)。注册表重载后即可使用最新版本。" )) } "build_tool" => { @@ -83,6 +102,7 @@ fn update_tool_project(tool_dir: &Path, tool_name: &str, params: &Value) -> Resu fs::write(tool_dir.join("src/main.rs"), code).map_err(|e| e.to_string())?; } + // spec 一旦由 tool_manager 管理,risk_level 恒为 high let should_update_spec = params.get("description").is_some() || params.get("params").is_some() || params.get("risk_level").is_some() @@ -101,7 +121,12 @@ fn update_tool_project(tool_dir: &Path, tool_name: &str, params: &Value) -> Resu } fn build_tool(tool_dir: &Path, tool_name: &str) -> Result<(), String> { - let output = Command::new("cargo") + let binary = tool_dir.join("target/release").join(tool_name); + + // 用 `timeout` 限制构建时长,超时返回 124;cargo 及其子进程都会被信号终止 + let output = Command::new("timeout") + .arg(BUILD_TIMEOUT_SECS.to_string()) + .arg("cargo") .arg("build") .arg("--release") .current_dir(tool_dir) @@ -109,11 +134,18 @@ fn build_tool(tool_dir: &Path, tool_name: &str) -> Result<(), String> { .map_err(|e| format!("启动 cargo 失败: {e}"))?; if !output.status.success() { + // 构建失败:删除可能残留的旧二进制,避免 reload 后仍调用旧版本造成"假成功" + let _ = fs::remove_file(&binary); + let code = output.status.code().unwrap_or(-1); let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("cargo build 失败: {}", stderr.trim())); + let reason = if code == 124 { + format!("构建超时({BUILD_TIMEOUT_SECS}s)") + } else { + stderr.trim().to_string() + }; + return Err(format!("cargo build 失败(退出码 {code}): {reason}")); } - let binary = tool_dir.join("target/release").join(tool_name); if !binary.exists() { return Err(format!("构建后未找到二进制: {}", binary.display())); } @@ -122,10 +154,11 @@ fn build_tool(tool_dir: &Path, tool_name: &str) -> Result<(), String> { fn locate_tools_root() -> Result { let exe = std::env::current_exe().map_err(|e| e.to_string())?; + // exe = tools/tool_manager/target/release/tool_manager let tool_dir = exe - .parent() - .and_then(Path::parent) - .and_then(Path::parent) + .parent() // .../target/release + .and_then(Path::parent) // .../target + .and_then(Path::parent) // .../tool_manager .ok_or_else(|| "无法定位 tool_manager 目录".to_string())?; tool_dir .parent() @@ -171,14 +204,13 @@ fn main() {{ } fn spec_yaml(tool_name: &str, params: &Value) -> String { + // 安全约束:tool_manager 产出的工具一律高风险,忽略调用方传入的 risk_level + let risk_level = "high"; + let timeout_secs = params["timeout_secs"].as_u64().unwrap_or(30).clamp(1, 300); + let desc = params["description"] .as_str() - .unwrap_or("由 tool_manager 创建的本地工具"); - let risk_level = match params["risk_level"].as_str() { - Some("high") => "high", - _ => "low", - }; - let timeout_secs = params["timeout_secs"].as_u64().unwrap_or(30).clamp(1, 300); + .unwrap_or("由 tool_manager 创建的本地工具(自动标记 high risk)"); let mut out = format!( "name: {tool_name}\n\ @@ -218,8 +250,10 @@ fn validate_tool_name(name: &str) -> Result<(), String> { if name.is_empty() { return Err("缺少 tool_name".to_string()); } - if matches!(name, "target" | "specs" | "src" | "." | "..") { - return Err("tool_name 使用了保留名称".to_string()); + if RESERVED_NAMES.contains(&name) { + return Err(format!( + "tool_name '{name}' 是保留名称,禁止 create/update/build" + )); } if !name .chars() @@ -253,6 +287,32 @@ fn truthy(value: Option<&Value>) -> bool { } } +/// 追加审计日志到 tools 根目录下的 .tool_audit.log +fn audit( + tools_root: &Path, + user_id: &str, + action: &str, + tool_name: &str, + success: bool, + detail: &str, +) { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // detail 可能很长,截断到 200 字节避免日志膨胀 + let detail: String = detail.chars().take(200).collect(); + let line = format!( + "{ts}\t{user_id}\t{action}\t{tool_name}\t{}\t{detail}\n", + if success { "ok" } else { "err" } + ); + let path = tools_root.join(".tool_audit.log"); + if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(&path) { + use std::io::Write; + let _ = f.write_all(line.as_bytes()); + } +} + fn result(content: &str) { println!("{}", json!({"type":"result","content":content})); } From 4f80e81b0ba5497882fa4b8529d948d1866b4c18 Mon Sep 17 00:00:00 2001 From: wunianxiao Date: Mon, 22 Jun 2026 10:47:01 +0800 Subject: [PATCH 2/9] =?UTF-8?q?security(executor):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=BA=A7=E5=AE=A1=E6=89=B9=20+=20env=20?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=20+=20=E7=BB=93=E6=9E=84=E5=8C=96?= =?UTF-8?q?=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-1 统一审批: 高风险工具在 spawn 前统一走审批,与后续消息类型 (result/http/db) 无关,堵住 stdio→result 绕过审批的漏洞。 移除 http/db 分支内重复审批(入口已授权)。 P0-4 env 白名单: spawn 前 cmd.env_clear(),仅注入 PATH + spec 声明 的 env 变量,避免工具读取宿主全部 API key/数据库密码。 tool_manager spec 补充 HOME/CARGO_HOME/RUSTUP_HOME 供 cargo 使用。 P1-10 结构化结果: 引入 ToolRunOutcome{Ok,Err{kind,message}} 与 ToolErrorKind 枚举,executor/registry dispatch 返回结构化结果。 daemon 与 CLI 据此判断成败,删除脆弱的 is_tool_error 字符串前缀 匹配;失败时记录 kind 便于追踪。 --- src/daemon.rs | 48 +++-- src/main.rs | 31 +-- src/tools/executor.rs | 178 ++++++++++++------ src/tools/registry.rs | 14 +- .../tool_manager/specs/tool_manager.tool.yaml | 4 + 5 files changed, 160 insertions(+), 115 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 9cf1382..6ed1aa5 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -49,6 +49,7 @@ use crate::llm::{ use crate::queue::{ConsumerChannels, EnqueueHandle, MessageKind, PipelineMessage, QueueRunner}; use crate::tools::approval::ApprovalManager; +use crate::tools::executor::ToolRunOutcome; use crate::tools::registry::RegistryManager; use crate::wechat::client::WeChatClient; use std::collections::HashMap; @@ -692,7 +693,7 @@ async fn handle_tool_call( eq.enqueue(msg).await; }) }; - let output = ctx + let outcome = ctx .registry .dispatch( target, @@ -704,11 +705,17 @@ async fn handle_tool_call( &wechat_cb, ) .await; - if target == "tool_manager" && !is_tool_error(&output) { + if target == "tool_manager" && !outcome.is_err() { let count = ctx.registry.reload().await; - format!("{output}\n\n工具注册表已重载,当前可用工具数: {count}") + format!( + "{}\n\n工具注册表已重载,当前可用工具数: {count}", + outcome.into_message() + ) } else { - output + if let ToolRunOutcome::Err { ref kind, .. } = outcome { + tracing::warn!(target:"ias::err","[tool] {target} 执行失败: {kind:?}"); + } + outcome.into_message() } } else { let params: serde_json::Value = serde_json::from_str(&arguments).unwrap_or_default(); @@ -734,7 +741,7 @@ async fn handle_tool_call( eq.enqueue(msg).await; }) }; - let output = ctx + let outcome = ctx .registry .dispatch( &tool_name, @@ -746,11 +753,17 @@ async fn handle_tool_call( &wechat_cb, ) .await; - if tool_name == "tool_manager" && !is_tool_error(&output) { + if tool_name == "tool_manager" && !outcome.is_err() { let count = ctx.registry.reload().await; - format!("{output}\n\n工具注册表已重载,当前可用工具数: {count}") + format!( + "{}\n\n工具注册表已重载,当前可用工具数: {count}", + outcome.into_message() + ) } else { - output + if let ToolRunOutcome::Err { ref kind, .. } = outcome { + tracing::warn!(target:"ias::err","[tool] {tool_name} 执行失败: {kind:?}"); + } + outcome.into_message() } }; @@ -767,25 +780,6 @@ async fn handle_tool_call( .await; } -fn is_tool_error(result: &str) -> bool { - result.starts_with("异常退出") - || result.starts_with("启动失败") - || result.starts_with("工具输出非JSON") - || result.starts_with("HTTP失败") - || result.starts_with("读取stdout失败") - || result.starts_with("stdout超时") - || result.starts_with("进程超时") - || result.starts_with("进程异常") - || result.starts_with("未知消息类型") - || result.starts_with("未知工具") - || result.starts_with("不支持的方法") - || result.starts_with("审批失败") - || result.starts_with("审批超时") - || result.starts_with("操作已被用户取消") - || result.starts_with("缺少必填参数") - || result.starts_with("工具 '") -} - fn unpack_call_capability_params(cp: &serde_json::Value) -> (&str, serde_json::Value) { let target = cp["name"].as_str().unwrap_or(""); let mut params = serde_json::Map::new(); diff --git a/src/main.rs b/src/main.rs index bfa97f6..d1bd959 100644 --- a/src/main.rs +++ b/src/main.rs @@ -529,7 +529,7 @@ async fn cmd_tool_dynamic(name: &str, args: &[String]) { + Send + Sync ) = &|_: &str, _: &str| Box::pin(async {}); - let result = registry + let outcome = registry .dispatch( name, &serde_json::Value::Object(params), @@ -541,28 +541,11 @@ async fn cmd_tool_dynamic(name: &str, args: &[String]) { ) .await; - // 检测是否执行出错(匹配 execute_loop 返回的所有错误前缀) - let is_error = result.starts_with("异常退出") - || result.starts_with("启动失败") - || result.starts_with("工具输出非JSON") - || result.starts_with("HTTP失败") - || result.starts_with("读取stdout失败") - || result.starts_with("stdout超时") - || result.starts_with("进程超时") - || result.starts_with("进程异常") - || result.starts_with("未知消息类型") - || result.starts_with("未知工具") - || result.starts_with("不支持的方法") - || result.starts_with("审批失败") - || result.starts_with("审批超时") - || result.starts_with("操作已被用户取消") - || result.starts_with("缺少必填参数") - || result.starts_with("工具 '"); - - if is_error { - eprintln!("❌ {result}"); - std::process::exit(1); - } else { - println!("{result}"); + match outcome { + crate::tools::executor::ToolRunOutcome::Ok(s) => println!("{s}"), + crate::tools::executor::ToolRunOutcome::Err { message, .. } => { + eprintln!("❌ {message}"); + std::process::exit(1); + } } } diff --git a/src/tools/executor.rs b/src/tools/executor.rs index 00e019f..6c0d0df 100644 --- a/src/tools/executor.rs +++ b/src/tools/executor.rs @@ -5,7 +5,11 @@ //! - `{"type":"db",...}` — 请求数据库操作 //! - `{"type":"result","content":"..."}` — 最终结果 //! -//! 所有消息必须是合法 JSON。审批在 http/db 操作前处理。 +//! 所有消息必须是合法 JSON。 +//! +//! ## 安全边界(授权层) +//! **工具级审批在入口统一执行**:高风险工具在 spawn 之前必须获得用户确认, +//! 与工具后续输出的消息类型(result/http/db)无关 —— 杜绝 stdio→result 绕过。 use std::sync::Arc; use std::time::Duration; @@ -24,6 +28,59 @@ struct HttpResponse { body: Value, } +// ─── 结构化结果 ─── + +/// 工具执行错误类别(供调用方区分,替代字符串前缀匹配) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToolErrorKind { + SpawnFailed, + StdoutReadFailed, + StdoutTimeout, + ProcessTimeout, + ProcessAbnormal, + NonJsonOutput, + UnknownMessageType, + UnsupportedMethod, + HttpFailed, + ApprovalFailed, + ApprovalTimeout, + ApprovalRejected, + MaxRoundsExceeded, + UnknownTool, + MissingParams, +} + +/// 工具执行返回值:成功带内容,失败带类别与消息 +#[derive(Debug, Clone)] +pub enum ToolRunOutcome { + Ok(String), + Err { + kind: ToolErrorKind, + message: String, + }, +} + +impl ToolRunOutcome { + pub fn err(kind: ToolErrorKind, message: impl Into) -> Self { + ToolRunOutcome::Err { + kind, + message: message.into(), + } + } + + pub fn is_err(&self) -> bool { + matches!(self, ToolRunOutcome::Err { .. }) + } + + /// 取出最终展示给 LLM 的消息文本 + pub fn into_message(self) -> String { + match self { + ToolRunOutcome::Ok(s) => s, + ToolRunOutcome::Err { message, .. } => message, + } + } +} + pub async fn execute_loop( spec: &ToolSpec, params: &Value, @@ -36,11 +93,32 @@ pub async fn execute_loop( + Send + Sync ), -) -> String { +) -> ToolRunOutcome { + // ── 统一授权边界:高风险工具在 spawn 前必须通过用户审批 ── + // 不依赖后续消息类型,覆盖 stdio/http/db 所有路径 + if spec.is_high_risk() && !approved { + let (code, rx) = match approval.create(user_id, &spec.name).await { + Ok((c, r)) => (c, r), + Err(e) => return ToolRunOutcome::err(ToolErrorKind::ApprovalFailed, format!("审批失败: {e}")), + }; + wechat_send( + user_id, + &format!("⚠️ {}\n确认码: {}\n回复确认码继续,回复0取消", spec.desc, code), + ) + .await; + match rx.await.unwrap_or(ApprovalDecision::Expired) { + ApprovalDecision::Approved => { /* 授权通过,继续执行 */ } + ApprovalDecision::Rejected => { + return ToolRunOutcome::err(ToolErrorKind::ApprovalRejected, "操作已被用户取消") + } + ApprovalDecision::Expired => { + return ToolRunOutcome::err(ToolErrorKind::ApprovalTimeout, "审批超时,操作已取消") + } + } + } + let start = std::time::Instant::now(); - let binary_path = spec.path.as_deref().unwrap_or(&spec.tool); - const MAX_ROUNDS: u32 = 20; let mut response: Option = None; @@ -51,7 +129,10 @@ pub async fn execute_loop( round += 1; if round > MAX_ROUNDS { error!(target: "ias::err", "[loop] 工具 '{}' 超过最大轮次 {}", spec.name, MAX_ROUNDS); - return format!("工具 '{}' 执行轮次过多(>{})", spec.name, MAX_ROUNDS); + return ToolRunOutcome::err( + ToolErrorKind::MaxRoundsExceeded, + format!("工具 '{}' 执行轮次过多(>{})", spec.name, MAX_ROUNDS), + ); } // ── 信封 ── @@ -69,7 +150,12 @@ pub async fn execute_loop( if let Some(c) = &spec.command { cmd.arg("--command").arg(c); } - // 按工具 spec 定义的 env 列表,从当前进程环境推送对应变量 + // 安全:清空继承环境,仅注入 PATH(子进程定位可执行)+ spec 声明的白名单变量, + // 避免工具读取宿主全部 API key、数据库密码等敏感变量 + cmd.env_clear(); + if let Ok(path) = std::env::var("PATH") { + cmd.env("PATH", path); + } for key in &spec.env { match std::env::var(key) { Ok(v) => { @@ -89,7 +175,7 @@ pub async fn execute_loop( Ok(c) => c, Err(e) => { error!(target: "ias::err","[spawn] {e}"); - return format!("启动失败: {e}"); + return ToolRunOutcome::err(ToolErrorKind::SpawnFailed, format!("启动失败: {e}")); } }; @@ -129,11 +215,14 @@ pub async fn execute_loop( Ok(Ok(l)) => l, Ok(Err(e)) => { let _ = child.start_kill(); - return format!("读取stdout失败: {e}"); + return ToolRunOutcome::err(ToolErrorKind::StdoutReadFailed, format!("读取stdout失败: {e}")); } Err(_) => { let _ = child.start_kill(); - return format!("stdout超时({}s)", spec.timeout_secs); + return ToolRunOutcome::err( + ToolErrorKind::StdoutTimeout, + format!("stdout超时({}s)", spec.timeout_secs), + ); } }; @@ -152,12 +241,15 @@ pub async fn execute_loop( Ok(Ok(s)) => s, Ok(Err(e)) => { error!(target: "ias::err","[process] {e}"); - return format!("进程异常: {e}"); + return ToolRunOutcome::err(ToolErrorKind::ProcessAbnormal, format!("进程异常: {e}")); } Err(_) => { let _ = child.start_kill(); error!(target: "ias::err","[timeout] {}s", spec.timeout_secs); - return format!("进程超时({}s)", spec.timeout_secs); + return ToolRunOutcome::err( + ToolErrorKind::ProcessTimeout, + format!("进程超时({}s)", spec.timeout_secs), + ); } }; @@ -168,7 +260,10 @@ pub async fn execute_loop( } else { stderr_trimmed.to_string() }; - return format!("异常退出({}): {}", status.code().unwrap_or(-1), reason); + return ToolRunOutcome::err( + ToolErrorKind::ProcessAbnormal, + format!("异常退出({}): {}", status.code().unwrap_or(-1), reason), + ); } // ── 解析 ── @@ -177,7 +272,10 @@ pub async fn execute_loop( Ok(v) => v, Err(_) => { warn!(target: "ias::err","[non-json] {:.100}", trimmed); - return format!("工具输出非JSON: {:.100}", trimmed); + return ToolRunOutcome::err( + ToolErrorKind::NonJsonOutput, + format!("工具输出非JSON: {:.100}", trimmed), + ); } }; @@ -186,30 +284,11 @@ pub async fn execute_loop( let content = msg["content"].as_str().unwrap_or(""); let elapsed = start.elapsed(); info!(target: "ias::tool", "[done] {} 完成, 耗时 {:?}, {} 轮", spec.name, elapsed, round); - return content.to_string(); + return ToolRunOutcome::Ok(content.to_string()); } Some("http") => { let method = msg["method"].as_str().unwrap_or("GET"); let url = msg["url"].as_str().unwrap_or(""); - let desc = msg["desc"].as_str().unwrap_or(""); - - if spec.is_high_risk() && !approved { - let (code, rx) = match approval.create(user_id, &spec.name).await { - Ok((c, r)) => (c, r), - Err(e) => return format!("审批失败: {e}"), - }; - wechat_send( - user_id, - &format!("⚠️ {}\n确认码: {}\n回复确认码继续,回复0取消", desc, code), - ) - .await; - let d = rx.await.unwrap_or(ApprovalDecision::Expired); - match d { - ApprovalDecision::Approved => { /* 继续执行 */ } - ApprovalDecision::Rejected => return "操作已被用户取消".to_string(), - ApprovalDecision::Expired => return "审批超时,操作已取消".to_string(), - } - } let client = reqwest::Client::builder() .timeout(Duration::from_secs(25)) @@ -227,7 +306,9 @@ pub async fn execute_loop( .await } ("POST", None) => client.post(url).send().await, - (o, _) => return format!("不支持的方法: {o}"), + (o, _) => { + return ToolRunOutcome::err(ToolErrorKind::UnsupportedMethod, format!("不支持的方法: {o}")) + } }; match resp { Ok(r) => { @@ -237,32 +318,13 @@ pub async fn execute_loop( } Err(e) => { error!(target: "ias::err","[http] {e}"); - return format!("HTTP失败: {e}"); + return ToolRunOutcome::err(ToolErrorKind::HttpFailed, format!("HTTP失败: {e}")); } } } Some("db") => { let op = msg["operation"].as_str().unwrap_or(""); let op_params = msg.get("params").cloned().unwrap_or(Value::Null); - let desc = msg["desc"].as_str().unwrap_or(op); - - if spec.is_high_risk() && !approved { - let (code, rx) = match approval.create(user_id, &spec.name).await { - Ok((c, r)) => (c, r), - Err(e) => return format!("审批失败: {e}"), - }; - wechat_send( - user_id, - &format!("⚠️ {}\n确认码: {}\n回复确认码继续,回复0取消", desc, code), - ) - .await; - let d = rx.await.unwrap_or(ApprovalDecision::Expired); - match d { - ApprovalDecision::Approved => { /* 继续执行 */ } - ApprovalDecision::Rejected => return "操作已被用户取消".to_string(), - ApprovalDecision::Expired => return "审批超时,操作已取消".to_string(), - } - } let result = match op { "read_memories" => memory.read_for(user_id, None).await, @@ -271,8 +333,7 @@ pub async fn execute_loop( if c.is_empty() { "缺少 content".into() } else { - let write_result = memory.write_for(user_id, c).await; - write_result + memory.write_for(user_id, c).await } } "delete_memory" => { @@ -290,7 +351,10 @@ pub async fn execute_loop( } _ => { warn!(target: "ias::err","[unknown-type] {}", msg["type"]); - return format!("未知消息类型: {}", msg["type"]); + return ToolRunOutcome::err( + ToolErrorKind::UnknownMessageType, + format!("未知消息类型: {}", msg["type"]), + ); } } } diff --git a/src/tools/registry.rs b/src/tools/registry.rs index f3d4bf3..9ad7cf4 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use crate::context::MemoryStore; use crate::tools::approval::ApprovalManager; -use crate::tools::executor; +use crate::tools::executor::{self, ToolErrorKind, ToolRunOutcome}; use crate::tools::parser; use crate::tools::spec::ToolSpec; use serde_json::Value; @@ -153,16 +153,16 @@ impl Registry { + Send + Sync ), - ) -> String { + ) -> ToolRunOutcome { let spec = match self.get(name) { Some(s) => s, None => { tracing::warn!(target:"ias::registry","未知: {name}"); - return format!("未知工具: {name}"); + return ToolRunOutcome::err(ToolErrorKind::UnknownTool, format!("未知工具: {name}")); } }; if let Some(err) = validate_required_params(spec, params) { - return err; + return ToolRunOutcome::err(ToolErrorKind::MissingParams, err); } executor::execute_loop( spec, @@ -215,20 +215,20 @@ impl RegistryManager { + Send + Sync ), - ) -> String { + ) -> ToolRunOutcome { let spec = { let registry = self.registry.read().await; match registry.get(name) { Some(spec) => spec.clone(), None => { tracing::warn!(target:"ias::registry","未知: {name}"); - return format!("未知工具: {name}"); + return ToolRunOutcome::err(ToolErrorKind::UnknownTool, format!("未知工具: {name}")); } } }; if let Some(err) = validate_required_params(&spec, params) { - return err; + return ToolRunOutcome::err(ToolErrorKind::MissingParams, err); } executor::execute_loop( diff --git a/tools/tool_manager/specs/tool_manager.tool.yaml b/tools/tool_manager/specs/tool_manager.tool.yaml index ba7429f..104f7ee 100644 --- a/tools/tool_manager/specs/tool_manager.tool.yaml +++ b/tools/tool_manager/specs/tool_manager.tool.yaml @@ -5,6 +5,10 @@ tool: tool_manager path: /target/release/tool_manager risk_level: high timeout_secs: 300 +env: + - HOME + - CARGO_HOME + - RUSTUP_HOME params: - name: action required: true From 1e33f527e03d89717d984ced518c8a9c0b69210e Mon Sep 17 00:00:00 2001 From: wunianxiao Date: Mon, 22 Jun 2026 10:48:16 +0800 Subject: [PATCH 3/9] =?UTF-8?q?fix(approval):=20clean=5Fexpired=20?= =?UTF-8?q?=E6=8C=89=20code=5Fhash=20=E5=AE=9A=E4=BD=8D=E5=88=A0=E9=99=A4?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=B9=B6=E5=8F=91=20index=20?= =?UTF-8?q?=E9=94=99=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-5: 原实现快照 (uid, index, code_hash) 后释放锁更新 DB,再重新加锁 按 index remove。若两阶段间 handle_reply 增删了条目,index 指向 错误条目,可能把未过期的审批误标 Expired 并通知等待方。 改为两阶段都按 code_hash 精确定位:收集过期 hash → 更新 DB → 重新加锁按 hash 查找删除,并发回复只会让查找安全跳过。 --- src/tools/approval.rs | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/src/tools/approval.rs b/src/tools/approval.rs index 11e7ce6..27dbc59 100644 --- a/src/tools/approval.rs +++ b/src/tools/approval.rs @@ -192,22 +192,32 @@ impl ApprovalManager { } /// 清理过期审批(按时间判定,通知等待方 + 更新 DB) + /// + /// 两阶段加锁:收集过期 code_hash → 更新 DB → 重新加锁按 code_hash 定位删除。 + /// 不依赖快照 index,避免并发回复导致 index 错位误删。 pub async fn clean_expired(&self) { let now = Instant::now(); - let mut expired = Vec::new(); + + // 第一阶段:持锁收集过期条目的 code_hash + let mut expired_hashes: Vec = Vec::new(); { let map = self.pending.lock().await; - for (uid, entries) in map.iter() { - for (i, e) in entries.iter().enumerate() { + for entries in map.values() { + for e in entries { if now > e.expires_at { - expired.push((uid.clone(), i, e.code_hash.clone())); + expired_hashes.push(e.code_hash.clone()); } } } } + if expired_hashes.is_empty() { + return; + } + + // DB 更新(按 code_hash 精确匹配,无错位问题) if let Some(ref pool) = self.pool { - for (_uid, _i, code_hash) in &expired { + for code_hash in &expired_hashes { let _ = sqlx::query( "UPDATE pending_approvals SET status = 'expired' WHERE code_hash = $1 AND status = 'pending'", ) @@ -217,13 +227,15 @@ impl ApprovalManager { } } + // 第二阶段:重新持锁,按 code_hash 定位并移除 + // (即使两阶段间 handle_reply 增删了条目,按 hash 查找也能安全跳过) let mut map = self.pending.lock().await; - for (uid, i, _) in expired.iter().rev() { - if let Some(entries) = map.get_mut(uid) - && *i < entries.len() - { - let entry = entries.remove(*i); - let _ = entry.sender.send(ApprovalDecision::Expired); + for code_hash in &expired_hashes { + for entries in map.values_mut() { + if let Some(idx) = entries.iter().position(|e| &e.code_hash == code_hash) { + let entry = entries.remove(idx); + let _ = entry.sender.send(ApprovalDecision::Expired); + } } } map.retain(|_, v| !v.is_empty()); From 20f7c3ba9c5aa53d9604ec78a66110929c604cbf Mon Sep 17 00:00:00 2001 From: wunianxiao Date: Mon, 22 Jun 2026 11:38:42 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat(sandbox):=20Docker=20=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E6=80=A7=E5=AE=B9=E5=99=A8=E6=89=A7=E8=A1=8C=E9=9A=94?= =?UTF-8?q?=E7=A6=BB=E8=BE=B9=E7=95=8C=EF=BC=88=E6=AD=A5=E9=AA=A4=206-7?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入两类安全边界:统一审批=授权边界(上一提交),一次性 Docker 容器=执行隔离边界(本提交)。LLM 即使执行恶意代码也只能在受限容器 内运行。 spec.rs: 新增 SandboxSpec(profile/network/memory/cpus/pids_limit/ read_only),默认 profile=local 不启用容器。 executor.rs: 按 sandbox.profile 选择执行后端 - local: 本地 spawn(env_clear + 白名单注入) - stdio: ias-stdio-runner,--network none --read-only --cap-drop ALL --security-opt no-new-privileges,仅 bind mount 工具二进制(ro)+tmpfs /tmp - network: ias-network-runner(预留自行联网工具) - builder: ias-tool-builder(供 tool_manager 编译) 全局开关 IAS_DOCKER_SANDBOX=1,未启用时行为不变(向后兼容)。 tool_manager: build_tool 支持 IAS_DOCKER_BUILDER=1 在 ias-tool-builder 容器内离线编译(--network none,挂载 tool_dir 到 /work)。 docker/: 三类 Dockerfile + build-images.sh 构建脚本。 - stdio-runner: debian-slim + ca-certificates,无网络只读 - network-runner: 预留联网工具 - tool-builder: rust:1.88-slim 预缓存 serde_json,支持离线编译 spec 迁移: 17 个普通工具追加 sandbox.profile: stdio;tool_manager 保持 local(自身在宿主操作文件系统)。 端到端验证: datetime 工具本地模式 +08:00,Docker 模式 +00:00(UTC), --rm 无容器残留。 --- .gitignore | 2 + docker/Dockerfile.network-runner | 8 ++ docker/Dockerfile.stdio-runner | 7 ++ docker/Dockerfile.tool-builder | 10 ++ docker/build-images.sh | 16 +++ src/tools/executor.rs | 111 ++++++++++++++---- src/tools/registry.rs | 2 + src/tools/spec.rs | 46 ++++++++ tools/amap/specs/amap_geocode.tool.yaml | 3 + tools/amap/specs/amap_map_link.tool.yaml | 3 + tools/amap/specs/amap_poi_search.tool.yaml | 3 + .../amap/specs/amap_reverse_geocode.tool.yaml | 3 + tools/amap/specs/amap_route_plan.tool.yaml | 3 + tools/amap/specs/amap_travel_plan.tool.yaml | 3 + .../specs/get_current_datetime.tool.yaml | 3 + tools/fetch_page/specs/fetch_page.tool.yaml | 3 + tools/memories/specs/delete_memory.tool.yaml | 3 + tools/memories/specs/get_memory.tool.yaml | 3 + tools/memories/specs/read_memories.tool.yaml | 3 + tools/memories/specs/update_memory.tool.yaml | 3 + tools/memories/specs/write_memory.tool.yaml | 3 + tools/tool_manager/src/main.rs | 40 +++++-- .../weather/specs/weather_forecast.tool.yaml | 3 + tools/weather/specs/weather_hourly.tool.yaml | 3 + tools/weather/specs/weather_now.tool.yaml | 3 + tools/web_search/specs/web_search.tool.yaml | 3 + 26 files changed, 263 insertions(+), 30 deletions(-) create mode 100644 docker/Dockerfile.network-runner create mode 100644 docker/Dockerfile.stdio-runner create mode 100644 docker/Dockerfile.tool-builder create mode 100755 docker/build-images.sh diff --git a/.gitignore b/.gitignore index 7c5c52c..641289e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ dist/ # 运行时数据(状态文件、缓存、聊天记录本地副本等) .data/ .pi +# tool_manager 审计日志 +tools/.tool_audit.log # 环境配置(含密钥,不入库) .env diff --git a/docker/Dockerfile.network-runner b/docker/Dockerfile.network-runner new file mode 100644 index 0000000..89f3935 --- /dev/null +++ b/docker/Dockerfile.network-runner @@ -0,0 +1,8 @@ +# ias-network-runner — 允许联网的工具运行环境 +# 供需要自行发起 HTTP 的工具使用(当前工具均通过宿主 http 消息代理联网, +# 此镜像为将来工具直接联网预留)。运行时仍可按需 --network none。 +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* +ENTRYPOINT [] diff --git a/docker/Dockerfile.stdio-runner b/docker/Dockerfile.stdio-runner new file mode 100644 index 0000000..9baab1e --- /dev/null +++ b/docker/Dockerfile.stdio-runner @@ -0,0 +1,7 @@ +# ias-stdio-runner — 运行普通 stdio 工具的极简隔离环境 +# 无网络、只读根、低权限。工具二进制通过 bind mount 提供,镜像本身不含工具。 +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +ENTRYPOINT [] diff --git a/docker/Dockerfile.tool-builder b/docker/Dockerfile.tool-builder new file mode 100644 index 0000000..577138d --- /dev/null +++ b/docker/Dockerfile.tool-builder @@ -0,0 +1,10 @@ +# ias-tool-builder — tool_manager 编译 Rust 工具的隔离环境 +# 预缓存 serde_json(tool_manager 生成工具的唯一依赖),支持 --network none 离线编译。 +# 运行时由 tool_manager 挂载 tool_dir 到 /work,cargo 在 /work 内编译,产物写回宿主。 +FROM rust:1.88-slim +RUN cargo new /tmp/seed && cd /tmp/seed \ + && printf 'serde_json = "1.0"\n' >> Cargo.toml \ + && cargo build --release \ + && rm -rf /tmp/seed +WORKDIR /work +ENTRYPOINT [] diff --git a/docker/build-images.sh b/docker/build-images.sh new file mode 100755 index 0000000..de206fb --- /dev/null +++ b/docker/build-images.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# 构建三类沙箱基础镜像。无需为每个工具单独写 Dockerfile。 +set -euo pipefail +cd "$(dirname "$0")" # docker/ + +echo "=== 构建 ias-stdio-runner(无网络只读容器)===" +docker build -t ias-stdio-runner -f Dockerfile.stdio-runner . + +echo "=== 构建 ias-network-runner(联网容器)===" +docker build -t ias-network-runner -f Dockerfile.network-runner . + +echo "=== 构建 ias-tool-builder(Rust 编译容器)===" +docker build -t ias-tool-builder -f Dockerfile.tool-builder . + +echo "=== 完成 ===" +docker images | grep -E '^ias-(stdio|network|tool)' diff --git a/src/tools/executor.rs b/src/tools/executor.rs index 6c0d0df..025ff98 100644 --- a/src/tools/executor.rs +++ b/src/tools/executor.rs @@ -145,27 +145,14 @@ pub async fn execute_loop( } // ── spawn ── - let binary = std::path::Path::new(binary_path); - let mut cmd = Command::new(&binary); - if let Some(c) = &spec.command { - cmd.arg("--command").arg(c); - } - // 安全:清空继承环境,仅注入 PATH(子进程定位可执行)+ spec 声明的白名单变量, - // 避免工具读取宿主全部 API key、数据库密码等敏感变量 - cmd.env_clear(); - if let Ok(path) = std::env::var("PATH") { - cmd.env("PATH", path); - } - for key in &spec.env { - match std::env::var(key) { - Ok(v) => { - cmd.env(key, &v); - } - Err(_) => { - warn!(target: "ias::err", "[env] 工具 '{}' 需要的环境变量 '{}' 未设置", spec.name, key); - } - } - } + // 执行隔离边界:启用沙箱时按 sandbox.profile 选择一次性 Docker 容器, + // 否则本地 spawn(env_clear + 白名单注入) + let use_docker = sandbox_enabled() && spec.sandbox.profile != "local"; + let mut cmd = if use_docker { + build_docker_cmd(spec, binary_path) + } else { + build_local_cmd(spec, binary_path) + }; cmd.stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -359,3 +346,85 @@ 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)); + if let Some(c) = &spec.command { + cmd.arg("--command").arg(c); + } + cmd.env_clear(); + if let Ok(path) = std::env::var("PATH") { + cmd.env("PATH", path); + } + for key in &spec.env { + match std::env::var(key) { + Ok(v) => { + cmd.env(key, &v); + } + Err(_) => { + warn!(target: "ias::err", "[env] 工具 '{}' 需要的环境变量 '{}' 未设置", spec.name, key); + } + } + } + cmd +} + +/// Docker 隔离执行:一次性容器,默认无网络、只读根、cap-drop ALL、 +/// no-new-privileges,仅挂载工具二进制(ro)+ tmpfs /tmp +fn build_docker_cmd(spec: &ToolSpec, binary_path: &str) -> Command { + let image = match spec.sandbox.profile.as_str() { + "network" => "ias-network-runner", + "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/registry.rs b/src/tools/registry.rs index 9ad7cf4..4d69a61 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -340,6 +340,7 @@ path: /target/release/mytool required: true, desc: "城市".into(), }], + sandbox: Default::default(), }; let err = validate_required_params(&spec, &serde_json::json!({})).unwrap(); @@ -363,6 +364,7 @@ path: /target/release/mytool required: true, desc: "城市".into(), }], + sandbox: Default::default(), }; assert!( diff --git a/src/tools/spec.rs b/src/tools/spec.rs index c4c5eab..2c6aca1 100644 --- a/src/tools/spec.rs +++ b/src/tools/spec.rs @@ -13,6 +13,48 @@ pub struct ParamSpec { pub desc: String, } +/// 沙箱配置(Docker 一次性容器隔离) +/// +/// ```yaml +/// sandbox: +/// profile: stdio # local | stdio | network | builder +/// network: none # none | default +/// memory: 256m +/// cpus: "0.5" +/// pids_limit: 64 +/// read_only: true +/// ``` +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(default)] +pub struct SandboxSpec { + /// 沙箱画像:`local` 本地执行(默认,不启用容器);`stdio` 无网络只读容器; + /// `network` 联网容器;`builder` 构建容器(供 tool_manager) + pub profile: String, + /// 网络:`none`(默认)或 `default` + pub network: String, + /// 内存限制,如 `256m` + pub memory: String, + /// CPU 限制,如 `0.5` + pub cpus: String, + /// PID 数量上限 + pub pids_limit: u32, + /// 只读根文件系统 + pub read_only: bool, +} + +impl Default for SandboxSpec { + fn default() -> Self { + Self { + profile: "local".into(), + network: "none".into(), + memory: "256m".into(), + cpus: "0.5".into(), + pids_limit: 64, + read_only: true, + } + } +} + /// 工具规范(`*.tool.yaml` 的完整映射) /// /// ```yaml @@ -56,6 +98,10 @@ pub struct ToolSpec { #[serde(default)] pub params: Vec, + + /// 沙箱配置(Docker 隔离);默认 profile=local 不启用容器 + #[serde(default)] + pub sandbox: SandboxSpec, } fn default_risk_level() -> String { diff --git a/tools/amap/specs/amap_geocode.tool.yaml b/tools/amap/specs/amap_geocode.tool.yaml index 3e516ff..0fcec20 100644 --- a/tools/amap/specs/amap_geocode.tool.yaml +++ b/tools/amap/specs/amap_geocode.tool.yaml @@ -15,3 +15,6 @@ params: - name: city required: false desc: 城市名称,可选。限定地理编码的城市范围 + +sandbox: + profile: stdio diff --git a/tools/amap/specs/amap_map_link.tool.yaml b/tools/amap/specs/amap_map_link.tool.yaml index ade1812..50a7b13 100644 --- a/tools/amap/specs/amap_map_link.tool.yaml +++ b/tools/amap/specs/amap_map_link.tool.yaml @@ -12,3 +12,6 @@ params: - name: center required: false desc: 地图中心点经纬度坐标,格式"经度,纬度",默认天安门坐标 + +sandbox: + profile: stdio diff --git a/tools/amap/specs/amap_poi_search.tool.yaml b/tools/amap/specs/amap_poi_search.tool.yaml index 57781b3..d2058bb 100644 --- a/tools/amap/specs/amap_poi_search.tool.yaml +++ b/tools/amap/specs/amap_poi_search.tool.yaml @@ -18,3 +18,6 @@ params: - name: radius required: false desc: 搜索半径(米),如 500、1000、3000。默认 1000 米 + +sandbox: + profile: stdio diff --git a/tools/amap/specs/amap_reverse_geocode.tool.yaml b/tools/amap/specs/amap_reverse_geocode.tool.yaml index 5fdd89b..62f066e 100644 --- a/tools/amap/specs/amap_reverse_geocode.tool.yaml +++ b/tools/amap/specs/amap_reverse_geocode.tool.yaml @@ -12,3 +12,6 @@ params: - name: location required: true desc: 经纬度坐标,格式为"经度,纬度",如"116.397,39.908" + +sandbox: + profile: stdio diff --git a/tools/amap/specs/amap_route_plan.tool.yaml b/tools/amap/specs/amap_route_plan.tool.yaml index c41f351..a8db3b9 100644 --- a/tools/amap/specs/amap_route_plan.tool.yaml +++ b/tools/amap/specs/amap_route_plan.tool.yaml @@ -18,3 +18,6 @@ params: - name: type required: false desc: 出行方式:driving(驾车)、walking(步行)、riding(骑行)、transit(公交)。默认 driving + +sandbox: + profile: stdio diff --git a/tools/amap/specs/amap_travel_plan.tool.yaml b/tools/amap/specs/amap_travel_plan.tool.yaml index ab69515..62d1874 100644 --- a/tools/amap/specs/amap_travel_plan.tool.yaml +++ b/tools/amap/specs/amap_travel_plan.tool.yaml @@ -15,3 +15,6 @@ params: - name: interests required: false desc: 感兴趣的关键词,逗号分隔,如"景点,美食,酒店"。默认"景点,美食" + +sandbox: + profile: stdio diff --git a/tools/datetime/specs/get_current_datetime.tool.yaml b/tools/datetime/specs/get_current_datetime.tool.yaml index afd8b1f..6181c8d 100644 --- a/tools/datetime/specs/get_current_datetime.tool.yaml +++ b/tools/datetime/specs/get_current_datetime.tool.yaml @@ -6,3 +6,6 @@ path: /target/release/datetime risk_level: low timeout_secs: 3 params: [] + +sandbox: + profile: stdio diff --git a/tools/fetch_page/specs/fetch_page.tool.yaml b/tools/fetch_page/specs/fetch_page.tool.yaml index 00c115c..309dc2a 100644 --- a/tools/fetch_page/specs/fetch_page.tool.yaml +++ b/tools/fetch_page/specs/fetch_page.tool.yaml @@ -9,3 +9,6 @@ params: - name: url required: true desc: 网页URL + +sandbox: + profile: stdio diff --git a/tools/memories/specs/delete_memory.tool.yaml b/tools/memories/specs/delete_memory.tool.yaml index 6b7499e..c56dfd0 100644 --- a/tools/memories/specs/delete_memory.tool.yaml +++ b/tools/memories/specs/delete_memory.tool.yaml @@ -12,3 +12,6 @@ params: - name: memory_id required: true desc: 记忆ID + +sandbox: + profile: stdio diff --git a/tools/memories/specs/get_memory.tool.yaml b/tools/memories/specs/get_memory.tool.yaml index cf1a93e..fcfc75a 100644 --- a/tools/memories/specs/get_memory.tool.yaml +++ b/tools/memories/specs/get_memory.tool.yaml @@ -12,3 +12,6 @@ params: - name: memory_id required: true desc: 记忆的数字ID,从 read_memories 返回结果中获取 + +sandbox: + profile: stdio diff --git a/tools/memories/specs/read_memories.tool.yaml b/tools/memories/specs/read_memories.tool.yaml index 30d0827..482a204 100644 --- a/tools/memories/specs/read_memories.tool.yaml +++ b/tools/memories/specs/read_memories.tool.yaml @@ -9,3 +9,6 @@ timeout_secs: 3 env: - IAS_MEMORY_DIR params: + +sandbox: + profile: stdio diff --git a/tools/memories/specs/update_memory.tool.yaml b/tools/memories/specs/update_memory.tool.yaml index 0038a6f..b92b2ee 100644 --- a/tools/memories/specs/update_memory.tool.yaml +++ b/tools/memories/specs/update_memory.tool.yaml @@ -15,3 +15,6 @@ params: - name: content required: true desc: 更新后的新内容,覆盖原记忆 + +sandbox: + profile: stdio diff --git a/tools/memories/specs/write_memory.tool.yaml b/tools/memories/specs/write_memory.tool.yaml index 2a7497c..318ad56 100644 --- a/tools/memories/specs/write_memory.tool.yaml +++ b/tools/memories/specs/write_memory.tool.yaml @@ -12,3 +12,6 @@ params: - name: content required: true desc: 要记录的记忆内容,用自然语言描述。如"用户住在合肥包河区联投新安里A区" + +sandbox: + profile: stdio diff --git a/tools/tool_manager/src/main.rs b/tools/tool_manager/src/main.rs index bb69e60..2df91c6 100644 --- a/tools/tool_manager/src/main.rs +++ b/tools/tool_manager/src/main.rs @@ -122,16 +122,38 @@ fn update_tool_project(tool_dir: &Path, tool_name: &str, params: &Value) -> Resu fn build_tool(tool_dir: &Path, tool_name: &str) -> Result<(), String> { let binary = tool_dir.join("target/release").join(tool_name); + let use_builder = std::env::var("IAS_DOCKER_BUILDER") + .ok() + .as_deref() + == Some("1"); - // 用 `timeout` 限制构建时长,超时返回 124;cargo 及其子进程都会被信号终止 - let output = Command::new("timeout") - .arg(BUILD_TIMEOUT_SECS.to_string()) - .arg("cargo") - .arg("build") - .arg("--release") - .current_dir(tool_dir) - .output() - .map_err(|e| format!("启动 cargo 失败: {e}"))?; + // 启用 builder 容器时在隔离环境内离线编译,挂载 tool_dir 到 /work,产物写回宿主; + // 否则本地 cargo(受 timeout 限制) + let output = if use_builder { + Command::new("docker") + .arg("run").arg("--rm") + .arg("--network").arg("none") + .arg("--memory").arg("1g") + .arg("--cpus").arg("1") + .arg("--pids-limit").arg("128") + .arg("--cap-drop").arg("ALL") + .arg("--security-opt").arg("no-new-privileges") + .arg("-v").arg(format!("{}:/work", tool_dir.display())) + .arg("-w").arg("/work") + .arg("ias-tool-builder") + .arg("timeout").arg(BUILD_TIMEOUT_SECS.to_string()) + .arg("cargo").arg("build").arg("--release") + .output() + } else { + Command::new("timeout") + .arg(BUILD_TIMEOUT_SECS.to_string()) + .arg("cargo") + .arg("build") + .arg("--release") + .current_dir(tool_dir) + .output() + } + .map_err(|e| format!("启动构建失败: {e}"))?; if !output.status.success() { // 构建失败:删除可能残留的旧二进制,避免 reload 后仍调用旧版本造成"假成功" diff --git a/tools/weather/specs/weather_forecast.tool.yaml b/tools/weather/specs/weather_forecast.tool.yaml index 59bd20f..dc9b7fb 100644 --- a/tools/weather/specs/weather_forecast.tool.yaml +++ b/tools/weather/specs/weather_forecast.tool.yaml @@ -12,3 +12,6 @@ params: - name: location required: true desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 + +sandbox: + profile: stdio diff --git a/tools/weather/specs/weather_hourly.tool.yaml b/tools/weather/specs/weather_hourly.tool.yaml index 9356910..556b82a 100644 --- a/tools/weather/specs/weather_hourly.tool.yaml +++ b/tools/weather/specs/weather_hourly.tool.yaml @@ -12,3 +12,6 @@ params: - name: location required: true desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 + +sandbox: + profile: stdio diff --git a/tools/weather/specs/weather_now.tool.yaml b/tools/weather/specs/weather_now.tool.yaml index 8dd1aa8..0c93562 100644 --- a/tools/weather/specs/weather_now.tool.yaml +++ b/tools/weather/specs/weather_now.tool.yaml @@ -12,3 +12,6 @@ params: - name: location required: true desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 + +sandbox: + profile: stdio diff --git a/tools/web_search/specs/web_search.tool.yaml b/tools/web_search/specs/web_search.tool.yaml index 4cca1da..2f7e722 100644 --- a/tools/web_search/specs/web_search.tool.yaml +++ b/tools/web_search/specs/web_search.tool.yaml @@ -13,3 +13,6 @@ params: desc: 搜索关键词,如"Rust 2024 edition 新特性"、"今天比特币价格" - name: max_results desc: 返回的最大搜索结果数量(1-20),默认5条 + +sandbox: + profile: stdio From cacd4436ceddbae62a263ec5a727901bff7c4a99 Mon Sep 17 00:00:00 2001 From: wunianxiao Date: Mon, 22 Jun 2026 11:49:31 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix(sandbox):=20builder=20=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=20--user=20uid:gid=20+=20=E7=A6=BB=E7=BA=BF=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=EF=BC=8C=E4=BF=AE=20cap-drop=20=E5=86=99=E6=9D=83?= =?UTF-8?q?=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 步骤 9: tool_manager build 迁移到 builder 容器验证通过。 问题: --cap-drop ALL 丢掉 CAP_DAC_OVERRIDE,容器内 root 无法写 bind mount 的非 root 目录(宿主 xiao:1000)。 这是 cap-drop 正确生效的安全行为,非 bug。 修复: - builder 容器 --user uid:gid 以宿主用户身份运行,自然能写自己 的目录,同时保留 cap-drop ALL + no-new-privileges。 - Cargo.toml 改 CARGO_HOME=/tmp/cargo + chmod 777,使任意 uid 都能复用 serde_json 预缓存。 - cargo build --offline + CARGO_NET_OFFLINE=true,配合 --network none 真正离线编译,杜绝恶意代码下载依赖。 端到端: tool_manager create_tool → builder 容器离线编译 → 产物运行成功,审计日志记录正确。 --- docker/Dockerfile.tool-builder | 5 ++++- tools/tool_manager/src/main.rs | 21 ++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docker/Dockerfile.tool-builder b/docker/Dockerfile.tool-builder index 577138d..9b73538 100644 --- a/docker/Dockerfile.tool-builder +++ b/docker/Dockerfile.tool-builder @@ -1,10 +1,13 @@ # ias-tool-builder — tool_manager 编译 Rust 工具的隔离环境 # 预缓存 serde_json(tool_manager 生成工具的唯一依赖),支持 --network none 离线编译。 +# CARGO_HOME=/tmp/cargo 并 chmod 777,使任意 uid(--user 运行)都能复用缓存。 # 运行时由 tool_manager 挂载 tool_dir 到 /work,cargo 在 /work 内编译,产物写回宿主。 FROM rust:1.88-slim +ENV CARGO_HOME=/tmp/cargo RUN cargo new /tmp/seed && cd /tmp/seed \ && printf 'serde_json = "1.0"\n' >> Cargo.toml \ && cargo build --release \ - && rm -rf /tmp/seed + && rm -rf /tmp/seed \ + && chmod -R 777 /tmp/cargo WORKDIR /work ENTRYPOINT [] diff --git a/tools/tool_manager/src/main.rs b/tools/tool_manager/src/main.rs index 2df91c6..6e26360 100644 --- a/tools/tool_manager/src/main.rs +++ b/tools/tool_manager/src/main.rs @@ -126,6 +126,7 @@ fn build_tool(tool_dir: &Path, tool_name: &str) -> Result<(), String> { .ok() .as_deref() == Some("1"); + let (uid, gid) = current_uid_gid(); // 启用 builder 容器时在隔离环境内离线编译,挂载 tool_dir 到 /work,产物写回宿主; // 否则本地 cargo(受 timeout 限制) @@ -138,11 +139,14 @@ fn build_tool(tool_dir: &Path, tool_name: &str) -> Result<(), String> { .arg("--pids-limit").arg("128") .arg("--cap-drop").arg("ALL") .arg("--security-opt").arg("no-new-privileges") + .arg("--user").arg(format!("{}:{}", uid, gid)) .arg("-v").arg(format!("{}:/work", tool_dir.display())) .arg("-w").arg("/work") + .arg("-e").arg("CARGO_NET_OFFLINE=true") + .arg("-e").arg("CARGO_HOME=/tmp/cargo") .arg("ias-tool-builder") .arg("timeout").arg(BUILD_TIMEOUT_SECS.to_string()) - .arg("cargo").arg("build").arg("--release") + .arg("cargo").arg("build").arg("--release").arg("--offline") .output() } else { Command::new("timeout") @@ -309,6 +313,21 @@ fn truthy(value: Option<&Value>) -> bool { } } +/// 获取当前进程的 uid:gid,用于 builder 容器 --user,使容器内进程能写 bind mount 的宿主目录 +fn current_uid_gid() -> (String, String) { + let uid = String::from_utf8_lossy( + &Command::new("id").arg("-u").output().map(|o| o.stdout).unwrap_or_default(), + ) + .trim() + .to_string(); + let gid = String::from_utf8_lossy( + &Command::new("id").arg("-g").output().map(|o| o.stdout).unwrap_or_default(), + ) + .trim() + .to_string(); + (uid, gid) +} + /// 追加审计日志到 tools 根目录下的 .tool_audit.log fn audit( tools_root: &Path, From 4a5b21a69e73b5d223fc76562767eebb1fda393b Mon Sep 17 00:00:00 2001 From: wunianxiao Date: Mon, 22 Jun 2026 11:51:05 +0800 Subject: [PATCH 6/9] =?UTF-8?q?docs:=20=E4=B8=A4=E5=B1=82=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E8=BE=B9=E7=95=8C=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 授权边界(统一审批)+ 隔离边界(Docker 一次性容器)+ 审计 的设计、实现与验证记录。 --- docs/SECURITY_BOUNDARIES.md | 143 ++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/SECURITY_BOUNDARIES.md diff --git a/docs/SECURITY_BOUNDARIES.md b/docs/SECURITY_BOUNDARIES.md new file mode 100644 index 0000000..1e45b18 --- /dev/null +++ b/docs/SECURITY_BOUNDARIES.md @@ -0,0 +1,143 @@ +# 两层安全边界设计 + +> 审批决定"能不能做",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`)无关。 + +```rust +if spec.is_high_risk() && !approved { + // 发送确认码 → 等待用户回复 → Approved/Rejected/Expired +} +``` + +**修复的漏洞**:原实现审批只在 `http`/`db` 消息分支触发,`stdio → result` 可绕过。`tool_manager`(high risk)直接输出 result 即可零确认执行。 + +## 第二层:一次性 Docker 容器(隔离边界) + +### 三类基础镜像 + +| 镜像 | 用途 | 网络 | 文件系统 | +|------|------|------|----------| +| `ias-stdio-runner` | 普通 stdio 工具 | none | 只读根 + tmpfs /tmp | +| `ias-network-runner` | 需联网的工具(预留) | default | 只读根 + 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) + +```yaml +sandbox: + profile: stdio # local | stdio | network | builder + network: none # none | default + memory: 256m + cpus: "0.5" + pids_limit: 64 + read_only: true +``` + +默认 `profile: local`(不启用容器)。现有非 tool_manager 工具均设为 `profile: stdio`。 + +### 启用沙箱 + +```bash +export IAS_DOCKER_SANDBOX=1 # 工具执行走一次性容器 +export IAS_DOCKER_BUILDER=1 # tool_manager 编译走 builder 容器 +``` + +未设置时退回本地执行(仍保留 env_clear + 白名单注入)。 + +### 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` 枚举替代裸字符串: + +```rust +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 | +| datetime 本地执行 | +08:00(宿主时区) | +| datetime Docker 执行 | +00:00(容器 UTC,证明确实隔离) | +| 容器残留检查 | 无(--rm 生效) | +| 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 工具迁移 | ⏸ 保持 stdio profile(工具走宿主代理联网,最小权限不给直接网络) | +| 9 | tool_manager build 迁移到 builder 容器 | ✅ | +| 10 | 结构化结果替换 is_tool_error | ✅ | From 092f3b78f2525b80e9d82ef1821d6e2da320ebdd Mon Sep 17 00:00:00 2001 From: wunianxiao Date: Mon, 22 Jun 2026 16:53:36 +0800 Subject: [PATCH 7/9] =?UTF-8?q?refactor(sandbox):=20=E6=8C=89=E5=A8=81?= =?UTF-8?q?=E8=83=81=E6=A8=A1=E5=9E=8B=E7=B2=BE=E7=A1=AE=E5=8C=96=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=BB=85=E4=B8=8D=E5=8F=97=E4=BF=A1=E4=BB=BB=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=BF=9B=E5=AE=B9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http/db 类工具的子进程是瘦壳(纯计算),危险操作全在受信任主进程, 套容器是纯开销无隔离收益。调整为: - 开发者预写工具:移除 spec 的 sandbox: profile: stdio,回默认 local 本地执行(仍保留 env_clear + 白名单注入) - tool_manager 产出的工具:spec_yaml 自动写 sandbox: profile: stdio, 不受信任代码(LLM 提供 rust_code)默认进 stdio 容器 - tool_manager 编译:仍走 builder 容器 - 移除 ias-network-runner Dockerfile(当前无工具直接联网) - executor docker 分支仅保留 stdio/builder,删 network 分支 验证:datetime 本地执行 +08:00;tool_manager 产出工具 spec 含 sandbox: profile: stdio 且容器内执行成功;25 测试通过。 --- docker/Dockerfile.network-runner | 8 ----- docker/build-images.sh | 10 +++---- docs/SECURITY_BOUNDARIES.md | 30 +++++++++++++------ src/tools/executor.rs | 4 +-- src/tools/spec.rs | 6 ++-- tools/amap/specs/amap_geocode.tool.yaml | 2 -- tools/amap/specs/amap_map_link.tool.yaml | 2 -- tools/amap/specs/amap_poi_search.tool.yaml | 2 -- .../amap/specs/amap_reverse_geocode.tool.yaml | 2 -- tools/amap/specs/amap_route_plan.tool.yaml | 2 -- tools/amap/specs/amap_travel_plan.tool.yaml | 2 -- .../specs/get_current_datetime.tool.yaml | 2 -- tools/fetch_page/specs/fetch_page.tool.yaml | 2 -- tools/memories/specs/delete_memory.tool.yaml | 2 -- tools/memories/specs/get_memory.tool.yaml | 2 -- tools/memories/specs/read_memories.tool.yaml | 2 -- tools/memories/specs/update_memory.tool.yaml | 2 -- tools/memories/specs/write_memory.tool.yaml | 2 -- tools/tool_manager/src/main.rs | 4 +++ .../weather/specs/weather_forecast.tool.yaml | 2 -- tools/weather/specs/weather_hourly.tool.yaml | 2 -- tools/weather/specs/weather_now.tool.yaml | 2 -- tools/web_search/specs/web_search.tool.yaml | 2 -- 23 files changed, 35 insertions(+), 61 deletions(-) delete mode 100644 docker/Dockerfile.network-runner diff --git a/docker/Dockerfile.network-runner b/docker/Dockerfile.network-runner deleted file mode 100644 index 89f3935..0000000 --- a/docker/Dockerfile.network-runner +++ /dev/null @@ -1,8 +0,0 @@ -# ias-network-runner — 允许联网的工具运行环境 -# 供需要自行发起 HTTP 的工具使用(当前工具均通过宿主 http 消息代理联网, -# 此镜像为将来工具直接联网预留)。运行时仍可按需 --network none。 -FROM debian:bookworm-slim -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl \ - && rm -rf /var/lib/apt/lists/* -ENTRYPOINT [] diff --git a/docker/build-images.sh b/docker/build-images.sh index de206fb..8454559 100755 --- a/docker/build-images.sh +++ b/docker/build-images.sh @@ -1,16 +1,16 @@ #!/usr/bin/env bash -# 构建三类沙箱基础镜像。无需为每个工具单独写 Dockerfile。 +# 构建沙箱基础镜像。 +# - ias-stdio-runner : 运行 tool_manager 产出的不受信任工具(无网络只读) +# - ias-tool-builder : tool_manager 编译 Rust 工具(离线) +# 无需为每个工具单独写 Dockerfile。 set -euo pipefail cd "$(dirname "$0")" # docker/ echo "=== 构建 ias-stdio-runner(无网络只读容器)===" docker build -t ias-stdio-runner -f Dockerfile.stdio-runner . -echo "=== 构建 ias-network-runner(联网容器)===" -docker build -t ias-network-runner -f Dockerfile.network-runner . - echo "=== 构建 ias-tool-builder(Rust 编译容器)===" docker build -t ias-tool-builder -f Dockerfile.tool-builder . echo "=== 完成 ===" -docker images | grep -E '^ias-(stdio|network|tool)' +docker images | grep -E '^ias-(stdio|tool)' diff --git a/docs/SECURITY_BOUNDARIES.md b/docs/SECURITY_BOUNDARIES.md index 1e45b18..db7ce2c 100644 --- a/docs/SECURITY_BOUNDARIES.md +++ b/docs/SECURITY_BOUNDARIES.md @@ -33,12 +33,22 @@ if spec.is_high_risk() && !approved { ## 第二层:一次性 Docker 容器(隔离边界) -### 三类基础镜像 +### 适用范围(按威胁模型精确化) + +http/db 类工具的子进程是"瘦壳"——危险操作(联网、DB 读写)全在受信任的主进程里, +给它们套容器是纯开销,**没有隔离收益**。因此: + +| 场景 | 是否容器隔离 | 原因 | +|------|------------|------| +| 开发者预写工具(weather/amap/...) | ❌ 本地执行 | 子进程纯计算,危险操作在主进程 | +| tool_manager 产出的工具 | ✅ stdio 容器 | 含 LLM 提供的 rust_code,不受信任 | +| tool_manager 编译 | ✅ builder 容器 | cargo 执行任意 build script/proc macro | + +### 两类基础镜像 | 镜像 | 用途 | 网络 | 文件系统 | |------|------|------|----------| -| `ias-stdio-runner` | 普通 stdio 工具 | none | 只读根 + tmpfs /tmp | -| `ias-network-runner` | 需联网的工具(预留) | default | 只读根 + tmpfs /tmp | +| `ias-stdio-runner` | tool_manager 产出的不受信任工具 | none | 只读根 + tmpfs /tmp | | `ias-tool-builder` | tool_manager 编译 Rust 工具 | none | 可写 /work(bind mount) | 构建:`docker/build-images.sh` @@ -58,7 +68,7 @@ if spec.is_high_risk() && !approved { ```yaml sandbox: - profile: stdio # local | stdio | network | builder + profile: stdio # local | stdio | builder network: none # none | default memory: 256m cpus: "0.5" @@ -66,7 +76,9 @@ sandbox: read_only: true ``` -默认 `profile: local`(不启用容器)。现有非 tool_manager 工具均设为 `profile: stdio`。 +默认 `profile: local`(不启用容器)。开发者预写工具保持默认。 +**tool_manager 产出的工具自动写入 `sandbox: profile: stdio`**(见 tool_manager `spec_yaml`), +不受信任代码默认进容器。 ### 启用沙箱 @@ -121,9 +133,9 @@ enum ToolRunOutcome { |------|------| | tool_manager update 自身 | ❌ 拒绝(保留名) | | create_tool risk_level=low | spec 强制 high | -| datetime 本地执行 | +08:00(宿主时区) | -| datetime Docker 执行 | +00:00(容器 UTC,证明确实隔离) | -| 容器残留检查 | 无(--rm 生效) | +| tool_manager 产出工具 spec | 自动含 `sandbox: profile: stdio` | +| datetime 本地执行 | +08:00(宿主时区,默认不套容器) | +| tool_manager 产出工具 Docker 执行 | 容器内运行成功 | | builder 容器编译 serde_json | ✅ 离线成功 | | cargo test | 25 passed | @@ -138,6 +150,6 @@ enum ToolRunOutcome { | 5 | 修 clean_expired 并发 | ✅ | | 6 | 三类 Docker 镜像 + sandbox spec | ✅ | | 7 | stdio 工具迁移到一次性容器 | ✅ | -| 8 | network 工具迁移 | ⏸ 保持 stdio profile(工具走宿主代理联网,最小权限不给直接网络) | +| 8 | network 工具迁移 | ➖ 不需要:工具走宿主代理联网,子进程纯计算,套容器无隔离收益 | | 9 | tool_manager build 迁移到 builder 容器 | ✅ | | 10 | 结构化结果替换 is_tool_error | ✅ | diff --git a/src/tools/executor.rs b/src/tools/executor.rs index 025ff98..55373ee 100644 --- a/src/tools/executor.rs +++ b/src/tools/executor.rs @@ -379,10 +379,10 @@ fn build_local_cmd(spec: &ToolSpec, binary_path: &str) -> Command { } /// Docker 隔离执行:一次性容器,默认无网络、只读根、cap-drop ALL、 -/// no-new-privileges,仅挂载工具二进制(ro)+ tmpfs /tmp +/// 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() { - "network" => "ias-network-runner", "builder" => "ias-tool-builder", _ => "ias-stdio-runner", }; diff --git a/src/tools/spec.rs b/src/tools/spec.rs index 2c6aca1..e45a734 100644 --- a/src/tools/spec.rs +++ b/src/tools/spec.rs @@ -17,7 +17,7 @@ pub struct ParamSpec { /// /// ```yaml /// sandbox: -/// profile: stdio # local | stdio | network | builder +/// profile: stdio # local | stdio | builder /// network: none # none | default /// memory: 256m /// cpus: "0.5" @@ -27,8 +27,8 @@ pub struct ParamSpec { #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct SandboxSpec { - /// 沙箱画像:`local` 本地执行(默认,不启用容器);`stdio` 无网络只读容器; - /// `network` 联网容器;`builder` 构建容器(供 tool_manager) + /// 沙箱画像:`local` 本地执行(默认,不启用容器);`stdio` 无网络只读容器 + /// (供 tool_manager 产出的不受信任工具);`builder` 构建容器(供 tool_manager) pub profile: String, /// 网络:`none`(默认)或 `default` pub network: String, diff --git a/tools/amap/specs/amap_geocode.tool.yaml b/tools/amap/specs/amap_geocode.tool.yaml index 0fcec20..2b5d263 100644 --- a/tools/amap/specs/amap_geocode.tool.yaml +++ b/tools/amap/specs/amap_geocode.tool.yaml @@ -16,5 +16,3 @@ params: required: false desc: 城市名称,可选。限定地理编码的城市范围 -sandbox: - profile: stdio diff --git a/tools/amap/specs/amap_map_link.tool.yaml b/tools/amap/specs/amap_map_link.tool.yaml index 50a7b13..fbfc541 100644 --- a/tools/amap/specs/amap_map_link.tool.yaml +++ b/tools/amap/specs/amap_map_link.tool.yaml @@ -13,5 +13,3 @@ params: required: false desc: 地图中心点经纬度坐标,格式"经度,纬度",默认天安门坐标 -sandbox: - profile: stdio diff --git a/tools/amap/specs/amap_poi_search.tool.yaml b/tools/amap/specs/amap_poi_search.tool.yaml index d2058bb..1bd4b01 100644 --- a/tools/amap/specs/amap_poi_search.tool.yaml +++ b/tools/amap/specs/amap_poi_search.tool.yaml @@ -19,5 +19,3 @@ params: required: false desc: 搜索半径(米),如 500、1000、3000。默认 1000 米 -sandbox: - profile: stdio diff --git a/tools/amap/specs/amap_reverse_geocode.tool.yaml b/tools/amap/specs/amap_reverse_geocode.tool.yaml index 62f066e..1632192 100644 --- a/tools/amap/specs/amap_reverse_geocode.tool.yaml +++ b/tools/amap/specs/amap_reverse_geocode.tool.yaml @@ -13,5 +13,3 @@ params: required: true desc: 经纬度坐标,格式为"经度,纬度",如"116.397,39.908" -sandbox: - profile: stdio diff --git a/tools/amap/specs/amap_route_plan.tool.yaml b/tools/amap/specs/amap_route_plan.tool.yaml index a8db3b9..18f5860 100644 --- a/tools/amap/specs/amap_route_plan.tool.yaml +++ b/tools/amap/specs/amap_route_plan.tool.yaml @@ -19,5 +19,3 @@ params: required: false desc: 出行方式:driving(驾车)、walking(步行)、riding(骑行)、transit(公交)。默认 driving -sandbox: - profile: stdio diff --git a/tools/amap/specs/amap_travel_plan.tool.yaml b/tools/amap/specs/amap_travel_plan.tool.yaml index 62d1874..58cb483 100644 --- a/tools/amap/specs/amap_travel_plan.tool.yaml +++ b/tools/amap/specs/amap_travel_plan.tool.yaml @@ -16,5 +16,3 @@ params: required: false desc: 感兴趣的关键词,逗号分隔,如"景点,美食,酒店"。默认"景点,美食" -sandbox: - profile: stdio diff --git a/tools/datetime/specs/get_current_datetime.tool.yaml b/tools/datetime/specs/get_current_datetime.tool.yaml index 6181c8d..547e9a0 100644 --- a/tools/datetime/specs/get_current_datetime.tool.yaml +++ b/tools/datetime/specs/get_current_datetime.tool.yaml @@ -7,5 +7,3 @@ risk_level: low timeout_secs: 3 params: [] -sandbox: - profile: stdio diff --git a/tools/fetch_page/specs/fetch_page.tool.yaml b/tools/fetch_page/specs/fetch_page.tool.yaml index 309dc2a..aa5ae69 100644 --- a/tools/fetch_page/specs/fetch_page.tool.yaml +++ b/tools/fetch_page/specs/fetch_page.tool.yaml @@ -10,5 +10,3 @@ params: required: true desc: 网页URL -sandbox: - profile: stdio diff --git a/tools/memories/specs/delete_memory.tool.yaml b/tools/memories/specs/delete_memory.tool.yaml index c56dfd0..ea05b1c 100644 --- a/tools/memories/specs/delete_memory.tool.yaml +++ b/tools/memories/specs/delete_memory.tool.yaml @@ -13,5 +13,3 @@ params: required: true desc: 记忆ID -sandbox: - profile: stdio diff --git a/tools/memories/specs/get_memory.tool.yaml b/tools/memories/specs/get_memory.tool.yaml index fcfc75a..c0cadb8 100644 --- a/tools/memories/specs/get_memory.tool.yaml +++ b/tools/memories/specs/get_memory.tool.yaml @@ -13,5 +13,3 @@ params: required: true desc: 记忆的数字ID,从 read_memories 返回结果中获取 -sandbox: - profile: stdio diff --git a/tools/memories/specs/read_memories.tool.yaml b/tools/memories/specs/read_memories.tool.yaml index 482a204..596a796 100644 --- a/tools/memories/specs/read_memories.tool.yaml +++ b/tools/memories/specs/read_memories.tool.yaml @@ -10,5 +10,3 @@ env: - IAS_MEMORY_DIR params: -sandbox: - profile: stdio diff --git a/tools/memories/specs/update_memory.tool.yaml b/tools/memories/specs/update_memory.tool.yaml index b92b2ee..2220471 100644 --- a/tools/memories/specs/update_memory.tool.yaml +++ b/tools/memories/specs/update_memory.tool.yaml @@ -16,5 +16,3 @@ params: required: true desc: 更新后的新内容,覆盖原记忆 -sandbox: - profile: stdio diff --git a/tools/memories/specs/write_memory.tool.yaml b/tools/memories/specs/write_memory.tool.yaml index 318ad56..aa7ea4c 100644 --- a/tools/memories/specs/write_memory.tool.yaml +++ b/tools/memories/specs/write_memory.tool.yaml @@ -13,5 +13,3 @@ params: required: true desc: 要记录的记忆内容,用自然语言描述。如"用户住在合肥包河区联投新安里A区" -sandbox: - profile: stdio diff --git a/tools/tool_manager/src/main.rs b/tools/tool_manager/src/main.rs index 6e26360..85feaab 100644 --- a/tools/tool_manager/src/main.rs +++ b/tools/tool_manager/src/main.rs @@ -269,6 +269,10 @@ fn spec_yaml(tool_name: &str, params: &Value) -> String { out.push_str("params: []\n"); } + // 安全约束:tool_manager 产出的工具不受信任(含 LLM 提供的 rust_code), + // 默认进 stdio 沙箱容器(无网络、只读根),防止数据外泄/越权文件读 + out.push_str("sandbox:\n profile: stdio\n"); + out } diff --git a/tools/weather/specs/weather_forecast.tool.yaml b/tools/weather/specs/weather_forecast.tool.yaml index dc9b7fb..45051c7 100644 --- a/tools/weather/specs/weather_forecast.tool.yaml +++ b/tools/weather/specs/weather_forecast.tool.yaml @@ -13,5 +13,3 @@ params: required: true desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 -sandbox: - profile: stdio diff --git a/tools/weather/specs/weather_hourly.tool.yaml b/tools/weather/specs/weather_hourly.tool.yaml index 556b82a..c60cca5 100644 --- a/tools/weather/specs/weather_hourly.tool.yaml +++ b/tools/weather/specs/weather_hourly.tool.yaml @@ -13,5 +13,3 @@ params: required: true desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 -sandbox: - profile: stdio diff --git a/tools/weather/specs/weather_now.tool.yaml b/tools/weather/specs/weather_now.tool.yaml index 0c93562..1331ab5 100644 --- a/tools/weather/specs/weather_now.tool.yaml +++ b/tools/weather/specs/weather_now.tool.yaml @@ -13,5 +13,3 @@ params: required: true desc: 城市名称,如"北京"、"上海"、"合肥"。支持地级市和县级市 -sandbox: - profile: stdio diff --git a/tools/web_search/specs/web_search.tool.yaml b/tools/web_search/specs/web_search.tool.yaml index 2f7e722..d4f2dda 100644 --- a/tools/web_search/specs/web_search.tool.yaml +++ b/tools/web_search/specs/web_search.tool.yaml @@ -14,5 +14,3 @@ params: - name: max_results desc: 返回的最大搜索结果数量(1-20),默认5条 -sandbox: - profile: stdio From 142efb070fb0ef52f2dffd3e7b7595723943e79a Mon Sep 17 00:00:00 2001 From: wunianxiao Date: Mon, 22 Jun 2026 17:11:05 +0800 Subject: [PATCH 8/9] =?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(()) } From a2f88b9b0145aed1f9bfb6e1fec7ac4a65b1749d Mon Sep 17 00:00:00 2001 From: wunianxiao Date: Mon, 22 Jun 2026 17:46:48 +0800 Subject: [PATCH 9/9] =?UTF-8?q?fix(sandbox):=20=E5=AE=B9=E5=99=A8=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E8=87=AA=E5=8A=A8=E9=99=8D=E7=BA=A7=20+=20cl?= =?UTF-8?q?ean=5Fexpired=20=E5=B9=B6=E5=8F=91=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1 降级机制: - sandbox 新增 DEGRADED 全局标志,ensure_container 失败时置位 - 新增 ensure_ready_or_degrade: exec 前检测容器运行状态, 不在则重建,重建失败置降级标志并返回 false - executor 调用 ensure_ready_or_degrade,降级时回退本地执行 (审批已在入口完成,安全边界不受影响) - spawn_child 保留 spawn 级失败兜底(docker 二进制缺失等) - 解决: IAS_DOCKER_SANDBOX=1 但 docker 异常时工具不再持续 SpawnFailed P1-2 并发回归测试: - approval 新增 create_with_ttl 测试入口(cfg(test)) - clean_expired_only_removes_expired_entries: 验证只删过期条目 - clean_expired_safe_when_no_pending: 空 pending 不 panic - clean_expired_skips_entries_already_removed_by_reply: 回归保护——clean_expired 第一阶段收集 hash 后、第二阶段删除前, 条目被 handle_reply 提前移除时,按 hash 查找应安全跳过不误删 P2-5 容器崩溃恢复: - ensure_ready_or_degrade 统一处理容器崩溃重建 - recover_if_crashed 保留供 spawn 级失败兜底调用 P2-6 build_exec_cmd 用 spec.tool 替代 path 文件名提取,消除隐式耦合 验证: 28 测试通过(原25+3),clippy 22<基线23 无新增警告, 端到端验证容器重建/降级/本地三种路径均正确。 --- src/daemon.rs | 5 ++- src/tools/approval.rs | 90 +++++++++++++++++++++++++++++++++++++++++++ src/tools/executor.rs | 64 ++++++++++++++++++++++++------ src/tools/sandbox.rs | 83 +++++++++++++++++++++++++++++++++++---- 4 files changed, 221 insertions(+), 21 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 9293b3d..8276b1e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -113,10 +113,11 @@ pub async fn run(database: &Arc, memory_store: &Arc) { )))); let registry = Arc::new(RegistryManager::load("tools")); - // 沙箱容器(如果启用):daemon 启动时创建长期容器,失败则回退本地执行 + // 沙箱容器(如果启用):daemon 启动时创建长期容器,失败则设置降级标志 + // executor 会据此回退本地执行,避免不受信任工具持续 SpawnFailed if crate::tools::sandbox::enabled() { if let Err(e) = crate::tools::sandbox::ensure_container("tools").await { - tracing::warn!(target: "ias::sandbox", "沙箱容器启动失败,回退本地执行: {e}"); + tracing::warn!(target: "ias::sandbox", "沙箱容器启动失败: {e}"); } } diff --git a/src/tools/approval.rs b/src/tools/approval.rs index 27dbc59..fcab5af 100644 --- a/src/tools/approval.rs +++ b/src/tools/approval.rs @@ -129,6 +129,33 @@ impl ApprovalManager { Ok((code_str, rx)) } + /// 测试专用:创建指定 TTL(秒)的审批请求 + #[cfg(test)] + async fn create_with_ttl( + &self, + user_id: &str, + skill_name: &str, + ttl_secs: u64, + ) -> Result<(String, oneshot::Receiver), String> { + let code: u32 = rand::rng().random_range(100000..999999); + let code_str = code.to_string(); + let code_hash = format!("{:x}", Sha256::digest(code_str.as_bytes())); + let (tx, rx) = oneshot::channel(); + + let mut map = self.pending.lock().await; + map.entry(user_id.to_string()) + .or_default() + .push(PendingEntry { + code_hash, + skill_name: skill_name.to_string(), + attempts_left: 3, + expires_at: Instant::now() + std::time::Duration::from_secs(ttl_secs), + sender: tx, + }); + + Ok((code_str, rx)) + } + /// 处理用户回复(由消息循环调用) /// 返回 (技能名, 决策);匹配失败返回 None pub async fn handle_reply( @@ -241,3 +268,66 @@ impl ApprovalManager { map.retain(|_, v| !v.is_empty()); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn clean_expired_only_removes_expired_entries() { + // 无 DB,纯内存 + let mgr = ApprovalManager::new(None); + + // 两条审批:一条立即过期(ttl=0),一条 300s 不过期 + let (_expired_code, expired_rx) = mgr.create_with_ttl("u1", "tool_a", 0).await.unwrap(); + let (live_code, live_rx) = mgr.create_with_ttl("u1", "tool_b", 300).await.unwrap(); + + // 立即过期的条目 expires_at 已成过去,sleep 确保时间推进 + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + mgr.clean_expired().await; + + // 过期条目应收到 Expired + assert_eq!(expired_rx.await.unwrap(), ApprovalDecision::Expired); + // 未过期条目不应被误删:handle_reply 用正确确认码应能匹配 + let (name, decision) = mgr.handle_reply("u1", &live_code).await.unwrap(); + assert_eq!(name, "tool_b"); + assert_eq!(decision, ApprovalDecision::Approved); + // live_rx 应收到 Approved(未被 clean_expired 误标记) + assert_eq!(live_rx.await.unwrap(), ApprovalDecision::Approved); + } + + #[tokio::test] + async fn clean_expired_safe_when_no_pending() { + // 空 pending 不应 panic + let mgr = ApprovalManager::new(None); + mgr.clean_expired().await; + // 重复调用也安全 + mgr.clean_expired().await; + } + + #[tokio::test] + async fn clean_expired_skips_entries_already_removed_by_reply() { + // 回归保护:clean_expired 第一阶段收集过期 hash 后、第二阶段删除前, + // 若该条目已被 handle_reply 提前处理,clean_expired 应安全跳过,不误删其他条目。 + // (原 bug:按快照 index 删除会导致误删;现按 code_hash 删除应安全跳过) + let mgr = ApprovalManager::new(None); + + // 两条同用户审批,都设为立即过期 + let (_code_a, rx_a) = mgr.create_with_ttl("u1", "tool_a", 0).await.unwrap(); + let (_code_b, rx_b) = mgr.create_with_ttl("u1", "tool_b", 0).await.unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + // 先用 handle_reply “0” 取消最早的条目(tool_a),模拟并发回复 + let (name, decision) = mgr.handle_reply("u1", "0").await.unwrap(); + assert_eq!(name, "tool_a"); + assert_eq!(decision, ApprovalDecision::Rejected); + assert_eq!(rx_a.await.unwrap(), ApprovalDecision::Rejected); + + // 再 clean_expired:tool_a 已被 remove,按 hash 查找应跳过; + // tool_b 仍在且过期,应被正确标记 Expired(不会被误当成 tool_a 跳过) + mgr.clean_expired().await; + assert_eq!(rx_b.await.unwrap(), ApprovalDecision::Expired); + } +} diff --git a/src/tools/executor.rs b/src/tools/executor.rs index 06361f5..6bee010 100644 --- a/src/tools/executor.rs +++ b/src/tools/executor.rs @@ -146,20 +146,15 @@ pub async fn execute_loop( // ── spawn ── // 执行隔离边界:启用沙箱且工具 profile 非 local 时,通过 docker exec - // 在长期容器内执行;否则本地 spawn(env_clear + 白名单注入) - let use_docker = - crate::tools::sandbox::enabled() && spec.sandbox.profile != "local"; - let mut cmd = if use_docker { - crate::tools::sandbox::build_exec_cmd(spec) + // 在长期容器内执行;否则本地 spawn(env_clear + 白名单注入)。 + // 沙箱降级(ensure 失败或容器崩溃未恢复)时自动回退本地执行。 + let use_docker = if crate::tools::sandbox::should_use_docker(&spec.sandbox.profile) { + // 执行前确保容器就绪:未运行则重建,重建失败则降级 + crate::tools::sandbox::ensure_ready_or_degrade("tools").await } else { - build_local_cmd(spec, binary_path) + false }; - cmd.stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - - let mut child = match cmd.spawn() { + let mut child = match spawn_child(spec, binary_path, use_docker).await { Ok(c) => c, Err(e) => { error!(target: "ias::err","[spawn] {e}"); @@ -370,3 +365,48 @@ fn build_local_cmd(spec: &ToolSpec, binary_path: &str) -> Command { } cmd } + +/// spawn 工具进程。docker exec 模式下若首次 spawn 失败,尝试检测容器崩溃并重建后重试一次; +/// 重建失败则降级为本地执行(保留可用性,但仍受审批约束)。 +async fn spawn_child( + spec: &ToolSpec, + binary_path: &str, + use_docker: bool, +) -> Result { + let make_cmd = || { + let mut cmd = if use_docker { + crate::tools::sandbox::build_exec_cmd(spec) + } else { + build_local_cmd(spec, binary_path) + }; + cmd.stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + cmd + }; + + if let Ok(child) = make_cmd().spawn() { + return Ok(child); + } + + // 首次 spawn 失败。docker 模式下可能是容器崩溃,尝试重建后重试一次 + if use_docker && crate::tools::sandbox::recover_if_crashed("tools").await { + tracing::info!(target: "ias::sandbox", "沙箱容器已重建,重试执行"); + if let Ok(child) = make_cmd().spawn() { + return Ok(child); + } + // 重建后仍失败,降级本地执行 + tracing::warn!(target: "ias::sandbox", "重建后 exec 仍失败,降级本地执行"); + } else if use_docker { + tracing::warn!(target: "ias::sandbox", "容器不可用,降级本地执行"); + } + + // 降级:本地 spawn(审批已在入口完成,安全边界不受影响) + build_local_cmd(spec, binary_path) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() +} diff --git a/src/tools/sandbox.rs b/src/tools/sandbox.rs index 15c16f9..aab2433 100644 --- a/src/tools/sandbox.rs +++ b/src/tools/sandbox.rs @@ -9,7 +9,8 @@ //! 5 秒后仍不退出发 SIGKILL,确保容器内不累积孤儿进程。executor 的 tokio timeout //! 作为外层保险,kill `docker exec` 客户端释放宿主资源。 -use std::path::{Path, PathBuf}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; use tokio::process::Command; use crate::tools::spec::ToolSpec; @@ -19,6 +20,10 @@ pub const CONTAINER_NAME: &str = "ias-sandbox"; /// 容器内产物挂载点 const MOUNT_TARGET: &str = "/tools"; +/// 降级标志:ensure_container 失败或容器崩溃后置 true, +/// executor 据此回退本地执行,避免不受信任工具持续 SpawnFailed +static DEGRADED: AtomicBool = AtomicBool::new(false); + /// 全局沙箱开关:`IAS_DOCKER_SANDBOX=1` 启用 pub fn enabled() -> bool { std::env::var("IAS_DOCKER_SANDBOX") @@ -27,13 +32,37 @@ pub fn enabled() -> bool { == Some("1") } +/// 当前是否处于降级状态(ensure 失败或容器崩溃) +pub fn degraded() -> bool { + DEGRADED.load(Ordering::SeqCst) +} + +/// 执行前检查:沙箱启用且未降级时才走 docker exec +pub fn should_use_docker(profile: &str) -> bool { + enabled() && profile != "local" && !degraded() +} + /// 确保 ias-sandbox 长期容器在运行。daemon 启动时调用。 /// /// - 容器已运行:直接复用 /// - 容器不存在/已停止:清理后重新创建 /// -/// 失败返回 Err,调用方应回退本地执行。 +/// 失败时设置降级标志,executor 据此回退本地执行。 pub async fn ensure_container(dir: &str) -> Result<(), String> { + match ensure_container_inner(dir).await { + Ok(()) => { + DEGRADED.store(false, Ordering::SeqCst); + Ok(()) + } + Err(e) => { + DEGRADED.store(true, Ordering::SeqCst); + tracing::warn!(target: "ias::sandbox", "沙箱降级为本地执行: {e}"); + Err(e) + } + } +} + +async fn ensure_container_inner(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}"))?; @@ -104,17 +133,57 @@ pub async fn ensure_container(dir: &str) -> Result<(), String> { Ok(()) } +/// exec 失败时调用:检测容器是否崩溃,崩溃则尝试重建。 +/// 重建成功返回 true(调用方可重试 exec);否则置降级标志返回 false。 +pub async fn recover_if_crashed(dir: &str) -> bool { + 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 { + // 容器在运行,exec 失败是其他原因(如工具二进制不存在),不降级 + return false; + } + + tracing::warn!(target: "ias::sandbox", "沙箱容器已崩溃,尝试重建"); + ensure_container(dir).await.is_ok() +} + +/// 执行前调用:沙箱启用且未降级时,确保容器在运行。 +/// 容器不在运行则重建;重建失败置降级标志并返回 false(调用方降级本地执行)。 +pub async fn ensure_ready_or_degrade(dir: &str) -> bool { + if !enabled() || degraded() { + return false; + } + 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 { + return true; + } + // 容器不在运行,尝试重建 + if ensure_container(dir).await.is_ok() { + true + } else { + tracing::warn!(target: "ias::sandbox", "容器不可用,降级本地执行"); + false + } +} + /// 构建 `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}"); + // 直接用 spec.tool 作为容器内文件名,语义清晰,不依赖 spec.path 格式 + let container_path = format!("{MOUNT_TARGET}/{}", spec.tool); let mut cmd = Command::new("docker"); cmd.arg("exec").arg("-i");