security(executor): 统一工具级审批 + env 白名单 + 结构化结果
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 便于追踪。
This commit is contained in:
+21
-27
@@ -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();
|
||||
|
||||
+7
-24
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+121
-57
@@ -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<String>) -> 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<HttpResponse> = 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"]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user