新增: AE-2025-04会话级授权(TrustKey目录级+同会话同类自动放行)

This commit is contained in:
2026-06-17 01:29:30 +08:00
parent 0202b5195e
commit 6ec191e750
9 changed files with 218 additions and 1 deletions

View File

@@ -560,6 +560,59 @@ pub(crate) async fn process_tool_calls(
match risk_level {
RiskLevel::Low => low_risk.push((draft, args)),
RiskLevel::Medium | RiskLevel::High => {
// AE-2025-04 会话级信任Session Trust首批 write_file / run_command
// 同会话已批准过同工具+同目录 → TrustKey 命中 → 自动放行(跳过 pending + 二次确认)。
// 命中后走与 F-05 去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径
// 但与 F-05 不同F-05 复用缓存 tool_result 跳过执行trust 放行**真实执行工具**
// (用户信任同目录同类操作,但仍要看每次的真实结果)。
let trust_hit = super::trust_key_for(&draft.name, &args)
.and_then(|key| {
if session.session_trust.contains(&key) {
Some(key)
} else {
None
}
});
if let Some(key) = trust_hit {
let dir_label = match &key {
super::TrustKey::Write { dir } | super::TrustKey::Execute { dir } => dir.clone(),
};
tracing::info!(
tool = %draft.name,
dir = %dir_label,
new_tool_call_id = %draft.id,
"[AE-2025-04] 会话信任命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)"
);
// emit 轻量 toast 事件(前端 AiChat.vue 显示"🔓 自动放行: tool(dir)")
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolAutoApproved {
id: draft.id.clone(),
tool: draft.name.clone(),
dir: dir_label,
conversation_id: Some(conv_id.to_string()),
});
// 真实执行工具(在 session 锁外,与 Low risk 并行执行闭包同款不持锁)
let exec_args = args.clone();
let exec_tool = draft.name.clone();
let exec_result = tools_arc.execute(&exec_tool, exec_args).await;
let (status, content) = match &exec_result {
Ok(val) => ("completed", val.to_string()),
Err(e) => ("failed", e.to_string()),
};
session.messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(content.clone()),
conversation_id: Some(conv_id.to_string()),
});
// AR-11数据变更类工具执行成功后 emit df-data-changed 联动刷新
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
if exec_result.is_ok() {
emit_data_changed(app_handle, &draft.name);
}
// 审计:trust 放行仍记一条(decided_by=auto_trust),留痕可追溯
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust")).await;
continue;
}
// F-05仅 High 查去重缓存Med 保持原审批流程
if matches!(risk_level, RiskLevel::High) {
if let Some(cached) = find_cached_high_risk_result(session, &draft.name, &args) {

View File

@@ -288,6 +288,18 @@ pub async fn ai_approve(
let args = approval.arguments.clone();
let id = tool_call_id.clone();
let conv_id = approval.conversation_id.clone();
// AE-2025-04 会话级信任:用户人工批准 write_file / run_command 时,把该工具+目录写入
// session_trust —— 下次同会话同类操作(同工具+同目录 TrustKey)自动放行,跳过 pending + 二次确认。
// 仅首批工具(write_file/run_command)命中 trust_key_for 的 Some,其余工具 noop(None 不写)。
if let Some(key) = super::trust_key_for(&approval.tool_name, &approval.arguments) {
if session.session_trust.insert(key.clone()) {
tracing::info!(
tool = %approval.tool_name,
key = ?key,
"[AE-2025-04] 人工审批通过,记录会话信任: 下次同类操作自动放行"
);
}
}
drop(session); // 释放锁后再执行
let exec_result = state.ai_tools.execute(&approval.tool_name, args.clone()).await;
@@ -1145,6 +1157,8 @@ pub async fn ai_conversation_create(
session.agent_language = None;
// F-01 阶段6: 清旧对话的 model_override,防新对话沿用上一次的「指定」模型。
session.model_override = None;
// AE-2025-04新建对话清会话信任,新会话重审(信任上下文相关,不跨对话)。
session.session_trust.clear();
Ok(serde_json::json!({ "id": id }))
}
@@ -1223,6 +1237,8 @@ pub async fn ai_conversation_switch(
// F-01 阶段6: 切换对话清旧 override,防新对话沿用上一次的「指定」模型
// (override 是单对话级 UI 选择,不跨对话持久化)。
session.model_override = None;
// AE-2025-04切换对话清会话信任,新会话重审(信任上下文相关,不跨对话)。
session.session_trust.clear();
Ok(serde_json::json!({
"id": record.id,

View File

@@ -34,7 +34,7 @@ pub mod stream_recv;
pub mod title;
pub mod tool_registry;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -97,6 +97,11 @@ pub enum AiChatEvent {
AiToolCallStarted { id: String, name: String, args: serde_json::Value, conversation_id: Option<String> },
/// 工具调用完成
AiToolCallCompleted { id: String, result: serde_json::Value, conversation_id: Option<String> },
/// 会话级信任自动放行AE-2025-04 Session Trust
/// 同会话已批准过同类操作(同工具+同目录),下次命中自动放行,前端显示轻量 toast。
/// 与 AiToolCallStarted/Completed 正交——这三个事件描述一次信任放行调用生命周期:
/// Started → AutoApprovedtoast 提示用户已自动放行)→ Completed。
AiToolAutoApproved { id: String, tool: String, dir: String, conversation_id: Option<String> },
/// 需要人工审批
/// diffAE-2025-03路径 B仅 write_file 注入(旧文件 vs 新内容行级 unified diff
/// 供审批卡即时预览。旧文件不存在(新建)或非 write_file 工具为 None。
@@ -171,6 +176,105 @@ pub enum SessionState {
AwaitingApproval,
}
// ============================================================
// AE-2025-04 会话级信任Session Trust
//
// 核心:信任上下文相关。同会话批准过同类操作(同工具+同目录)后自动放行;
// 换会话new/switch清空重审session_trust 随会话销毁,不落库)。
//
// TrustKey 按 操作 + 目录 粒度非文件级——文件级粒度过细LLM 每写一个文件都要重审,
// 信任面太窄失去意义;目录级让"批准过同目录写入"覆盖该目录后续写入,对齐"写→跑→看→改"
// 闭环最高频场景。首批write_file + run_command。
//
// FR-S1 安全边界TrustKey 只含 tool + dir**绝不**含 api_key / 文件内容 / 命令串等敏感
// 参数。dir 经 resolve_workspace_path/canonicalize 规范化(去 .. / symlink 逃逸)。
// ============================================================
/// 会话级信任键:按「操作类型 + 目录」粒度唯一标识一类已批准操作。
///
/// - `Write { dir }`write_file 在 `dir` 目录下(路径父目录归一化)
/// - `Execute { dir }`run_command 在 `dir` 工作目录下
///
/// 命中即自动放行,跳过 pending 审批 + 二次确认AE-05不写 audit 仍记一条
/// `decided_by=auto_trust` 的审计记录留痕。
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub enum TrustKey {
Write { dir: String },
Execute { dir: String },
}
/// 由「工具名 + args」推算 TrustKey若该工具不在首批信任名单内返 None走原审批流程
///
/// 目录归一化策略(与 tool_registry.rs 各 handler 的路径锚定一致):
/// - write_file取 args.pathresolve_workspace_path → 父目录;解析失败回退 workspace_root
/// - run_command取 args.working_dir缺省回退 workspace_root不做越界校验
/// run_command handler 自身仅 validate_path 不限定 workspace信任目录用 LLM 传入或
/// workspace_root 默认值作为 key 字面量,与 handler 实际 working_dir 对齐)
///
/// FR-S1不读 api_key / content / command 等敏感字段,只取 path/working_dir 归一化为 dir。
pub fn trust_key_for(tool: &str, args: &serde_json::Value) -> Option<TrustKey> {
match tool {
"write_file" => {
let path = args.get("path").and_then(|v| v.as_str())?;
let dir = dir_of_path_normalized(path);
Some(TrustKey::Write { dir })
}
"run_command" => {
let dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => normalize_dir_key(d),
None => workspace_root_str(),
};
Some(TrustKey::Execute { dir })
}
_ => None,
}
}
/// write_file 路径 → 父目录规范化字符串(作 HashSet key 用)。
///
/// resolve_workspace_path 失败(路径越界/含 .. )回退 workspace_root —— 失败的工具调用本身
/// 不会被执行handler 内校验拦截),但 trust_key 计算不应 panic失败回退保守默认。
fn dir_of_path_normalized(path: &str) -> String {
match self::tool_registry::resolve_workspace_path_pub(path) {
Ok(resolved) => {
let parent = resolved
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
normalize_dir_key(&parent.to_string_lossy())
}
Err(_) => workspace_root_str(),
}
}
/// run_command / write_file 共用的目录 key 规范化:
/// 尝试 canonicalize去 symlink / .. / 大小写归一),失败回退原字面量 trim。
fn normalize_dir_key(dir: &str) -> String {
let p = std::path::Path::new(dir);
match p.canonicalize() {
Ok(canon) => canon.to_string_lossy().to_string(),
Err(_) => dir.trim_end_matches(['/', '\\']).to_string(),
}
}
/// workspace_root 字符串(作 run_command working_dir 缺省时的 trust key 回退)。
///
/// 与 tool_registry.rs workspace_root() 同源CARGO_MANIFEST_DIR 上两级canonicalize 后
/// 保证与 run_command handler 默认 working_dir = workspace_root().to_string() 生成的 key 一致。
fn workspace_root_str() -> String {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
root.canonicalize()
.map(|c| c.to_string_lossy().to_string())
.unwrap_or_else(|_| root.to_string_lossy().to_string())
}
// 引入 PathBuf 供 workspace_root_str / dir_of_path_normalized 使用
use std::path::PathBuf;
/// AI 会话内状态Mutex 保护)
pub struct AiSession {
/// 对话历史ContextManager唯一消息真相源裁剪仅影响发送视图不影响持久化
@@ -235,6 +339,11 @@ pub struct AiSession {
/// (防上一对话 override 残留污染新对话)。try_continue_agent_loop 读此字段透传给续跑
/// loop(审批续跑/达 max 续跑保持 override 一致,语义=同一主对话)。
pub model_override: Option<String>,
/// AE-2025-04 会话级信任Session Trust内存态随会话销毁不落库。
/// 同会话已批准过同类操作(同工具+同目录 TrustKey 命中)后自动放行,跳过 pending 审批 +
/// 二次确认AE-05。换会话new/switch必须清空重审见 ai_conversation_create /
/// ai_conversation_switch 清字段)。
pub session_trust: HashSet<TrustKey>,
}
impl AiSession {
@@ -251,6 +360,7 @@ impl AiSession {
notify: Arc::new(tokio::sync::Notify::new()),
iteration_used: 0,
model_override: None,
session_trust: HashSet::new(),
}
}

View File

@@ -239,6 +239,17 @@ fn workspace_root() -> PathBuf {
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
fn resolve_workspace_path(path: &str) -> anyhow::Result<PathBuf> {
resolve_workspace_path_impl(path)
}
/// AE-2025-04mod.rs trust_key_for 计算 write_file 的 TrustKey 目录粒度时复用本函数
/// 归一化路径(去 .. / symlink 逃逸。pub(crate) wrapper 转调私有 impl保持原私有函数
/// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。
pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> {
resolve_workspace_path_impl(path)
}
fn resolve_workspace_path_impl(path: &str) -> anyhow::Result<PathBuf> {
validate_path(path)?;
let root = workspace_root();
let resolved = if Path::new(path).is_absolute() {