Files
iAs/src/tools/approval.rs
T
wunianxiao a2f88b9b01 fix(sandbox): 容器不可用自动降级 + clean_expired 并发回归测试
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 无新增警告,
端到端验证容器重建/降级/本地三种路径均正确。
2026-06-22 17:46:48 +08:00

334 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! ## 审批管理器 —— 高风险工具的用户确认流程
//!
//! 当 LLM 调用高风险工具时,需要用户通过微信输入确认码来批准。
//!
//! ### 完整流程
//! 1. `create(user_id, skill_name)` → 生成 6 位随机确认码,SHA-256 哈希后存储
//! 2. 向用户发送 "⚠️ 操作确认\n\n技能:{name}\n确认码:{code}"
//! 3. 用户在微信聊天中输入确认码
//! 4. `handle_reply(user_id, reply)` → 验证确认码哈希
//! - 匹配 → `Approved`
//! - 不匹配 → 减少尝试次数(最多 3 次)
//! - 输入 0 或"取消" → `Rejected`
//! - 超时 → `Expired`(每 60 秒清理一次)
//!
//! ### 安全设计
//! - 确认码用 SHA-256 哈希后存储,原始码不持久化
//! - 每次生成随机 6 位数字(100000-999999
//! - 最多 3 次尝试,超时 5 分钟
//! - 同时支持内存和数据库双写
use rand::Rng;
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::{Mutex, oneshot};
/// ## 审批决策
///
/// * `Approved` — 用户输入了正确的确认码
/// * `Rejected` — 用户拒绝了(输入 0 或"取消"
/// * `Expired` — 超时未确认(5 分钟有效)
#[derive(Debug, Clone, PartialEq)]
pub enum ApprovalDecision {
Approved,
Rejected,
Expired,
}
/// 内存中待审批的条目
struct PendingEntry {
code_hash: String,
skill_name: String,
attempts_left: i32,
expires_at: Instant,
/// 通知等待方
sender: oneshot::Sender<ApprovalDecision>,
}
/// ## 审批管理器
///
/// 管理高风险工具的用户确认流程。
///
/// ### 流程
/// 1. `create(user_id, skill_name)` → 生成 6 位随机确认码,哈希后存储
/// 2. 向用户发送"⚠️ 操作确认\n\n技能:{name}\n确认码:{code}"
/// 3. 用户在微信聊天中输入确认码
/// 4. `handle_reply(user_id, reply)` → 验证确认码哈希
/// - 匹配 → Approved
/// - 不匹配 → 减少尝试次数(最多 3 次)
/// - 输入 0 或"取消" → Rejected
/// - 超时 → Expired(每 60 秒清理一次)
///
/// ### 存储
/// * 内存 — `HashMap<user_id, Vec<PendingEntry>>`(快速访问)
/// * 数据库 — `pending_approvals` 表(持久化审计)
pub struct ApprovalManager {
pool: Option<Arc<PgPool>>,
/// user_id → 该用户的待审批队列
pending: Arc<Mutex<HashMap<String, Vec<PendingEntry>>>>,
}
impl ApprovalManager {
pub fn new(pool: Option<Arc<PgPool>>) -> Self {
Self {
pool,
pending: Arc::new(Mutex::new(HashMap::new())),
}
}
/// 创建审批请求
/// 返回 (确认码明文, 接收器) — 调用方 await 接收器等待用户确认
pub async fn create(
&self,
user_id: &str,
skill_name: &str,
) -> Result<(String, oneshot::Receiver<ApprovalDecision>), 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();
// 入库
if let Some(ref pool) = self.pool {
let id = uuid::Uuid::new_v4();
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(5);
let params = serde_json::json!({"name": skill_name});
let _ = sqlx::query(
r#"
INSERT INTO pending_approvals (id, expires_at, user_id, skill_name, params, code_hash, status)
VALUES ($1, $2, $3, $4, $5, $6, 'pending')
"#,
)
.bind(id)
.bind(expires_at)
.bind(user_id)
.bind(skill_name)
.bind(&params)
.bind(&code_hash)
.execute(pool.as_ref())
.await;
}
// 注册到内存
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(300),
sender: tx,
});
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<ApprovalDecision>), 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(
&self,
user_id: &str,
reply: &str,
) -> Option<(String, ApprovalDecision)> {
let (result, name, code_hash) = {
let mut map = self.pending.lock().await;
let entries = map.get_mut(user_id)?;
if entries.is_empty() {
return None;
}
let reply_hash = format!("{:x}", Sha256::digest(reply.as_bytes()));
let reply_trimmed = reply.trim();
if reply_trimmed == "0" || reply_trimmed == "取消" {
let entry = entries.remove(0);
let hash = entry.code_hash.clone();
let _ = entry.sender.send(ApprovalDecision::Rejected);
(ApprovalDecision::Rejected, entry.skill_name.clone(), hash)
} else if let Some(idx) = entries.iter().position(|e| e.code_hash == reply_hash) {
let entry = entries.remove(idx);
let hash = entry.code_hash.clone();
let _ = entry.sender.send(ApprovalDecision::Approved);
(ApprovalDecision::Approved, entry.skill_name.clone(), hash)
} else if let Some(entry) = entries.first_mut() {
entry.attempts_left -= 1;
if entry.attempts_left <= 0 {
let entry = entries.remove(0);
let n = entry.skill_name.clone();
let hash = entry.code_hash.clone();
let _ = entry.sender.send(ApprovalDecision::Rejected);
(ApprovalDecision::Rejected, n, hash)
} else {
return None;
}
} else {
return None;
}
};
// 更新数据库状态
if let Some(ref pool) = self.pool {
let status = match result {
ApprovalDecision::Approved => "approved",
_ => "rejected",
};
// 按 user_id + 最近 pending 记录更新
let _ = sqlx::query(
"UPDATE pending_approvals SET status = $1, consumed_at = NOW() WHERE code_hash = $2 AND status = 'pending'",
)
.bind(status)
.bind(&code_hash)
.execute(pool.as_ref())
.await;
}
Some((name, result))
}
/// 清理过期审批(按时间判定,通知等待方 + 更新 DB)
///
/// 两阶段加锁:收集过期 code_hash → 更新 DB → 重新加锁按 code_hash 定位删除。
/// 不依赖快照 index,避免并发回复导致 index 错位误删。
pub async fn clean_expired(&self) {
let now = Instant::now();
// 第一阶段:持锁收集过期条目的 code_hash
let mut expired_hashes: Vec<String> = Vec::new();
{
let map = self.pending.lock().await;
for entries in map.values() {
for e in entries {
if now > e.expires_at {
expired_hashes.push(e.code_hash.clone());
}
}
}
}
if expired_hashes.is_empty() {
return;
}
// DB 更新(按 code_hash 精确匹配,无错位问题)
if let Some(ref pool) = self.pool {
for code_hash in &expired_hashes {
let _ = sqlx::query(
"UPDATE pending_approvals SET status = 'expired' WHERE code_hash = $1 AND status = 'pending'",
)
.bind(code_hash)
.execute(pool.as_ref())
.await;
}
}
// 第二阶段:重新持锁,按 code_hash 定位并移除
// (即使两阶段间 handle_reply 增删了条目,按 hash 查找也能安全跳过)
let mut map = self.pending.lock().await;
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());
}
}
#[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_expiredtool_a 已被 remove,按 hash 查找应跳过;
// tool_b 仍在且过期,应被正确标记 Expired(不会被误当成 tool_a 跳过)
mgr.clean_expired().await;
assert_eq!(rx_b.await.unwrap(), ApprovalDecision::Expired);
}
}