新增: AE-2025-04会话级授权(TrustKey目录级+同会话同类自动放行)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 → AutoApproved(toast 提示用户已自动放行)→ Completed。
|
||||
AiToolAutoApproved { id: String, tool: String, dir: String, conversation_id: Option<String> },
|
||||
/// 需要人工审批
|
||||
/// diff(AE-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.path,resolve_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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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-04:mod.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() {
|
||||
|
||||
@@ -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
|
||||
} | {
|
||||
|
||||
@@ -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()
|
||||
// 分离模式:接管主窗口当前对话(含生成中态),保持正在进行的对话
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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})',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -160,5 +160,9 @@ export default {
|
||||
collapse: '收起',
|
||||
// 压缩摘要块标题(展开后 system 摘要消息前的标签)
|
||||
compressedSummaryLabel: '上下文摘要',
|
||||
|
||||
// ── AE-2025-04 会话级信任(Session Trust) ──
|
||||
// 自动放行 toast(同会话已批准过同类操作:同工具+同目录,轻量 info 非审批气泡)
|
||||
autoApprovedToast: '已自动放行: {tool}({dir})',
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user