新增: 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() {

View File

@@ -246,6 +246,10 @@ export type AiChatEvent = ({
type: 'AiToolCallStarted'; id: string; name: string; args: unknown
} | {
type: 'AiToolCallCompleted'; id: string; result: unknown
} | {
// AE-2025-04 会话级信任:同会话已批准过同类操作(同工具+同目录)自动放行,前端显示轻量 toast。
// 不写消息/不动 pending(Started/Completed 仍独立发),仅提示性事件。
type: 'AiToolAutoApproved'; id: string; tool: string; dir: string
} | {
type: 'AiApprovalRequired'; id: string; name: string; args: unknown; reason: string; diff?: string
} | {

View File

@@ -936,6 +936,8 @@ function showToast(msg: string, type: 'error' | 'warning' | 'info' = 'info') {
// B-260616-12: 工具慢执行提示事件 unlistener(onMounted 注册,onBeforeUnmount 释放)
let _unlistenToolSlow: (() => void) | null = null
// AE-2025-04: 会话信任自动放行 toast unlistener(useAiEvents 经事件总线中转,同 _unlistenToolSlow 模式)
let _unlistenAutoApproved: (() => void) | null = null
const inputText = ref('')
const inputEl = ref<HTMLTextAreaElement>()
@@ -1813,6 +1815,7 @@ onBeforeUnmount(() => {
// F-15 阶段2: 释放上下文管理事件监听器
store.stopContextListener()
if (_unlistenToolSlow) { _unlistenToolSlow(); _unlistenToolSlow = null } // B-260616-12
if (_unlistenAutoApproved) { _unlistenAutoApproved(); _unlistenAutoApproved = null } // AE-2025-04
if (_toastTimer) { clearTimeout(_toastTimer); _toastTimer = null }
// UX-2025-20:释放标题淡入计时器
if (_titleFlashTimer) { clearTimeout(_titleFlashTimer); _titleFlashTimer = null }
@@ -1901,6 +1904,10 @@ onMounted(async () => {
_unlistenToolSlow = await listen<{ name: string }>('ai-tool-slow-toast', (e) => {
showToast(t('aiTool.executionSlow', { name: e.payload.name }), 'warning')
})
// AE-2025-04: 会话信任自动放行 toast(轻量 info,非审批气泡,避免干扰主流程)
_unlistenAutoApproved = await listen<{ tool: string; dir: string }>('ai-tool-auto-approved-toast', (e) => {
showToast(t('aiChat.autoApprovedToast', { tool: e.payload.tool, dir: e.payload.dir }), 'info')
})
// F-15 阶段2: 注册上下文管理事件监听器(清空/压缩生命周期事件 → toast/刷新)
await initContextEventListeners()
// 分离模式:接管主窗口当前对话(含生成中态),保持正在进行的对话

View File

@@ -227,6 +227,14 @@ export function handleEvent(event: AiChatEvent) {
break
}
case 'AiToolAutoApproved': {
// AE-2025-04 会话级信任:不写消息/不动 pending(Started/Completed 仍独立发),
// 仅经事件总线桥接到 AiChat.vue 弹本地 toast(composable 无组件上下文,与
// ai-tool-slow-toast 同款中转模式)。主窗口与分离窗口各挂一份 AiChat,各自消费。
void emit('ai-tool-auto-approved-toast', { tool: event.tool, dir: event.dir })
break
}
case 'AiApprovalRequired': {
clearStreamWatchdog() // 等用户审批,不计超时
const info: AiToolCallInfo = {

View File

@@ -160,5 +160,9 @@ export default {
collapse: 'Collapse',
// Compressed summary block label (before the system summary message when expanded)
compressedSummaryLabel: 'Context summary',
// ── AE-2025-04 Session Trust ──
// Auto-approval toast (same-session already approved same-kind op: same tool + same dir)
autoApprovedToast: 'Auto-approved: {tool}({dir})',
},
}

View File

@@ -160,5 +160,9 @@ export default {
collapse: '收起',
// 压缩摘要块标题(展开后 system 摘要消息前的标签)
compressedSummaryLabel: '上下文摘要',
// ── AE-2025-04 会话级信任(Session Trust) ──
// 自动放行 toast(同会话已批准过同类操作:同工具+同目录,轻量 info 非审批气泡)
autoApprovedToast: '已自动放行: {tool}({dir})',
},
}