新增: 路径授权三档化 once/session/always
Phase B 弹窗 once/always → once/session/always 三档(本次单次执行后清/当前会话切会话清/始终落KV)。修 once 原写 session 致本次实为本会话名实不符。AllowedDirs 加 once 层 + ai_authorize_dir decision 改 match 四分支 + 执行后 clear_once。
This commit is contained in:
@@ -581,6 +581,7 @@
|
|||||||
> **与 MCP Server(F-260619-02)无关**:本需求是 DevFlow 应用内部 AI 工具(read_file/write_file 等)的文件系统访问权限升级,不涉及对外 MCP 协议暴露。
|
> **与 MCP Server(F-260619-02)无关**:本需求是 DevFlow 应用内部 AI 工具(read_file/write_file 等)的文件系统访问权限升级,不涉及对外 MCP 协议暴露。
|
||||||
|
|
||||||
- [x] **F-260619-03 [P1]** ✅ — **AI 工具文件访问动态权限模型**(Phase A 持久白名单 + Phase B 会话临时/弹窗 + Phase C 黑名单 + workspace_root 去固定根完整,CR-09 ✅ + b22e9ae reload 尊重用户 persistent)
|
- [x] **F-260619-03 [P1]** ✅ — **AI 工具文件访问动态权限模型**(Phase A 持久白名单 + Phase B 会话临时/弹窗 + Phase C 黑名单 + workspace_root 去固定根完整,CR-09 ✅ + b22e9ae reload 尊重用户 persistent)
|
||||||
|
- **2026-06-24 三档授权演进**:Phase B 弹窗从 once/always 两档 → **once/session/always 三档**(本次单次执行后清 / 当前会话切会话清 / 始终落KV)。修 once 原写 session 致"本次"实为"本会话"名实不符(确定性 bug)。AllowedDirs 加 `once` 层(state.rs)+ ai_authorize_dir decision 改 match 四分支(chat.rs)+ 执行后 clear_once + tracing 审计 + 前端 DirAuthDialog 加"当前会话"按钮 + useAiApproval/api/types.ts/i18n 联合类型扩 session。cargo check 0 + vue-tsc 0。审查见待审查 CR-260624-01。
|
||||||
|
|
||||||
**核心机制**:当 AI 调用 `read_file` / `write_file` 等文件系统工具时,路径校验从「单一 workspace_root 前缀匹配」升级为「动态白名单池校验 + 未命中则挂起 Agentic Loop 向前端弹窗申请授权」。
|
**核心机制**:当 AI 调用 `read_file` / `write_file` 等文件系统工具时,路径校验从「单一 workspace_root 前缀匹配」升级为「动态白名单池校验 + 未命中则挂起 Agentic Loop 向前端弹窗申请授权」。
|
||||||
|
|
||||||
|
|||||||
12
docs/待审查.md
12
docs/待审查.md
@@ -23,6 +23,18 @@
|
|||||||
|
|
||||||
## 当前队列
|
## 当前队列
|
||||||
|
|
||||||
|
### CR-260624-01 AI 路径授权三档化(once/session/always) — ✅ 已审(PASS·单 agent + 6-agent 对抗深审 0 finding)
|
||||||
|
|
||||||
|
- **范围**:AllowedDirs 加 `once` 层(state.rs:444)+ ai_authorize_dir decision 改 match 四分支 once/session/always(chat.rs:846)+ 执行后 clear_once(chat.rs:924)+ 前端 DirAuthDialog 加"当前会话"按钮 + useAiApproval.ts/api/ai.ts/api/types.ts decision 联合类型扩 'session' + i18n zh/en 加 dirAuthSession。
|
||||||
|
- **维度**:
|
||||||
|
- (a) **once 清空时机**:clear_once 在 execute 后无条件清(state.rs:924 附近 chat.rs)。竞态:同 once 路径并发工具调用?现状工具串行(per_conv 单 generating),无并发,OK——但核验确认。
|
||||||
|
- (b) **session vs once 语义边界**:session 切会话清(conversation.rs clear_session)/ once 执行后清 / reload_allowed_dirs 保留 both(state.rs:810 take once + session)。核 reload 是否漏保留 once。
|
||||||
|
- (c) **is_authorized 读 once**:state.rs is_authorized 三层 `persistent || session || once` 任一命中。核 once 命中分支存在。
|
||||||
|
- (d) **前端全链路**:DirAuthDialog handleAuthorize / useAiApproval approveToolCall / api.authorizeDir 三处 decision 联合类型均含 'session',types.ts decision: string 不受限(无类型保护,但 IPC 接受任意字符串,match _ 兜底)。
|
||||||
|
- **核验方式**:grep `once|session` 贯穿 state.rs/chat.rs/前端 + 核 clear_once 调用点 + deny 分支(早 return,once 未写,不需清,核确认)。
|
||||||
|
- **关注点**:批量 approve 仍用 once(单卡语义,不批量 always,useAiApproval:110);always 降级写 session(原降级 add_session,语义对齐)。
|
||||||
|
- **审查结果**(2026-06-25):单 agent 审查(a2b2b35,6 维度全过)+ workflow 对抗深审(w9waeyg36,6 agent 并行 review + 每条 finding 独立 verify,**0 confirmed finding**)。once 清空时机(串行无并发误清)/ session-once 边界 / is_authorized 三层读 / deny 不触发 clear_once / 前端类型一致 全过。
|
||||||
|
|
||||||
### CR-260622-01 AI Chat 真实会话实测分析(84dc36c8·prompt 1.32M/completion 7.5K·3P0+3P1+2P2)— ✅ 已审(ISSUES·低优残留2项·P0清零)
|
### CR-260622-01 AI Chat 真实会话实测分析(84dc36c8·prompt 1.32M/completion 7.5K·3P0+3P1+2P2)— ✅ 已审(ISSUES·低优残留2项·P0清零)
|
||||||
|
|
||||||
- **范围**:实测数据反推(**非静态代码审查**)。从生产 DB 会话 `84dc36c8`(D:\Downloads\kms,整理三知识库 HTML)全量 86 条消息反推上下文/工具/agent 行为缺陷。报告 `docs/05-代码审查/aichat-会话实测分析-2026-06-22.md`,分析脚本 `.temp/analyze_session.py`,DB 只读 `%APPDATA%/top.1216.devflow/devflow-dev.db`。
|
- **范围**:实测数据反推(**非静态代码审查**)。从生产 DB 会话 `84dc36c8`(D:\Downloads\kms,整理三知识库 HTML)全量 86 条消息反推上下文/工具/agent 行为缺陷。报告 `docs/05-代码审查/aichat-会话实测分析-2026-06-22.md`,分析脚本 `.temp/analyze_session.py`,DB 只读 `%APPDATA%/top.1216.devflow/devflow-dev.db`。
|
||||||
|
|||||||
@@ -751,9 +751,10 @@ fn append_cross_drive_note(result: serde_json::Value, note: &str) -> serde_json:
|
|||||||
/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending
|
/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending
|
||||||
/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。
|
/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。
|
||||||
///
|
///
|
||||||
/// `decision`:
|
/// `decision`(三授权档 + 拒):
|
||||||
/// - `"once"`:写入 `AppState.allowed_dirs.session`(进程级会话临时授权,随 active 会话切换/新建/删除由 clear_session_allowed_dirs 清空)→ 执行工具 → 恢复 loop
|
/// - `"once"`:写入 `allowed_dirs.once`(本次单次,执行后 clear_once_allowed_dirs 清空)→ 执行 → 恢复
|
||||||
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存)→ 执行 → 恢复
|
/// - `"session"`:写入 `allowed_dirs.session`(当前会话,切换/新建/删除活跃会话由 clear_session_allowed_dirs 清空)→ 执行 → 恢复
|
||||||
|
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存 persistent)→ 执行 → 恢复
|
||||||
/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop
|
/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop
|
||||||
///
|
///
|
||||||
/// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result →
|
/// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result →
|
||||||
@@ -843,23 +844,33 @@ pub async fn ai_authorize_dir(
|
|||||||
return Ok("rejected".to_string());
|
return Ok("rejected".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── "once":写会话级临时授权(随会话销毁,不落库) ──
|
// ── 授权档写入(三档:once 本次单次 / session 当前会话 / always 始终落 KV)──
|
||||||
// L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项)。
|
// L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项),确保 handler 放行。
|
||||||
if decision == "once" {
|
// deny 已在上方早 return,此处仅 once/session/always。
|
||||||
for d in &dir_strs {
|
match decision.as_str() {
|
||||||
state.add_session_allowed_dir(d.clone()).await;
|
"once" => {
|
||||||
|
for d in &dir_strs {
|
||||||
|
state.add_once_allowed_dir(d.clone()).await;
|
||||||
|
}
|
||||||
|
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=once(本次单次,执行后清)");
|
||||||
}
|
}
|
||||||
}
|
"session" => {
|
||||||
|
for d in &dir_strs {
|
||||||
// ── "always":写持久化授权(Settings KV + 同步内存 persistent) ──
|
|
||||||
if decision == "always" {
|
|
||||||
for d in &dir_strs {
|
|
||||||
if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await {
|
|
||||||
tracing::warn!("[F-03B] 持久化授权目录失败(降级仅本次会话): dir={} err={}", d, e);
|
|
||||||
// 降级:写入会话临时授权,保本路径本会话可用
|
|
||||||
state.add_session_allowed_dir(d.clone()).await;
|
state.add_session_allowed_dir(d.clone()).await;
|
||||||
}
|
}
|
||||||
|
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=session(当前会话,切会话清)");
|
||||||
}
|
}
|
||||||
|
"always" => {
|
||||||
|
for d in &dir_strs {
|
||||||
|
if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await {
|
||||||
|
tracing::warn!("[F-03B] 持久化授权目录失败(降级当前会话): dir={} err={}", d, e);
|
||||||
|
// 降级:写入会话临时授权,保本路径本会话可用
|
||||||
|
state.add_session_allowed_dir(d.clone()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=always(始终,落 KV)");
|
||||||
|
}
|
||||||
|
_ => {} // deny 已上方早 return;未知 decision 不写授权(执行会再走预校验弹窗)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ──
|
// ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ──
|
||||||
@@ -908,6 +919,10 @@ pub async fn ai_authorize_dir(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// once 授权档语义=本次单次:工具执行完(handler 已读 once 放行)立即清空,下次同路径仍弹窗。
|
||||||
|
// 仅 once 档写入 once 层,无条件清空安全(session/always 不写 once,清空无副作用)。
|
||||||
|
state.clear_once_allowed_dirs().await;
|
||||||
|
|
||||||
// 替换占位 tool_result 为真实结果
|
// 替换占位 tool_result 为真实结果
|
||||||
{
|
{
|
||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
|
|||||||
@@ -436,10 +436,14 @@ pub struct AllowedDirs {
|
|||||||
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
|
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
|
||||||
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
|
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
|
||||||
pub persistent: HashSet<PathBuf>,
|
pub persistent: HashSet<PathBuf>,
|
||||||
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"仅本次"写入,进程级内存)。
|
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"当前会话"选项写入,进程级内存)。
|
||||||
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
|
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
|
||||||
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
|
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
|
||||||
pub session: HashSet<PathBuf>,
|
pub session: HashSet<PathBuf>,
|
||||||
|
/// 本次单次授权目录(弹窗"本次"选项写入)。单次工具执行放行后由 clear_once_allowed_dirs
|
||||||
|
/// 清空(用完即弃,下次同路径再访问仍弹窗)。区别于 session(整会话有效)。
|
||||||
|
/// 三档授权语义:once=本次单次 / session=当前会话 / persistent=始终落 KV。
|
||||||
|
pub once: HashSet<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AllowedDirs {
|
impl AllowedDirs {
|
||||||
@@ -451,7 +455,7 @@ impl AllowedDirs {
|
|||||||
pub fn default_with_root() -> Self {
|
pub fn default_with_root() -> Self {
|
||||||
let mut set = HashSet::new();
|
let mut set = HashSet::new();
|
||||||
set.insert(workspace_root_path());
|
set.insert(workspace_root_path());
|
||||||
Self { persistent: set, session: HashSet::new() }
|
Self { persistent: set, session: HashSet::new(), once: HashSet::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。
|
/// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。
|
||||||
@@ -484,6 +488,7 @@ impl AllowedDirs {
|
|||||||
let cand = strip_verbatim(best_effort_canonicalize(candidate));
|
let cand = strip_verbatim(best_effort_canonicalize(candidate));
|
||||||
self.persistent.iter().any(|d| cand.starts_with(d))
|
self.persistent.iter().any(|d| cand.starts_with(d))
|
||||||
|| self.session.iter().any(|d| cand.starts_with(d))
|
|| self.session.iter().any(|d| cand.starts_with(d))
|
||||||
|
|| self.once.iter().any(|d| cand.starts_with(d))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -806,7 +811,8 @@ impl AppState {
|
|||||||
// 仅覆盖 persistent。用户改 Settings 不影响当前会话已临时授权的目录。
|
// 仅覆盖 persistent。用户改 Settings 不影响当前会话已临时授权的目录。
|
||||||
let mut guard = self.allowed_dirs.write().await;
|
let mut guard = self.allowed_dirs.write().await;
|
||||||
let preserved_session = std::mem::take(&mut guard.session);
|
let preserved_session = std::mem::take(&mut guard.session);
|
||||||
*guard = AllowedDirs { persistent: set, session: preserved_session };
|
let preserved_once = std::mem::take(&mut guard.once);
|
||||||
|
*guard = AllowedDirs { persistent: set, session: preserved_session, once: preserved_once };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。
|
/// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。
|
||||||
@@ -890,6 +896,28 @@ impl AppState {
|
|||||||
pub async fn clear_session_allowed_dirs(&self) {
|
pub async fn clear_session_allowed_dirs(&self) {
|
||||||
self.allowed_dirs.write().await.session.clear();
|
self.allowed_dirs.write().await.session.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// F-260619-03 Phase B: 追加本次单次授权目录(弹窗"本次"选项)。
|
||||||
|
///
|
||||||
|
/// 写入 `allowed_dirs.once`(进程级内存,不落库)。handler 执行工具时 read lock
|
||||||
|
/// 取快照读 once 放行,执行后由 clear_once_allowed_dirs 清空(本次放行即失效,
|
||||||
|
/// 下次同路径仍弹窗)。规范化与 add_session_allowed_dir 一致(canonicalize 失败回退 trim)。
|
||||||
|
pub async fn add_once_allowed_dir(&self, dir: String) {
|
||||||
|
let d = dir.trim();
|
||||||
|
if d.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let p = PathBuf::from(d);
|
||||||
|
let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| {
|
||||||
|
PathBuf::from(d.trim_end_matches(['/', '\\']))
|
||||||
|
}));
|
||||||
|
self.allowed_dirs.write().await.once.insert(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// F-260619-03 Phase B: 清空本次单次授权(工具执行后调,本次放行即失效)。
|
||||||
|
pub async fn clear_once_allowed_dirs(&self) {
|
||||||
|
self.allowed_dirs.write().await.once.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。
|
/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。
|
||||||
@@ -967,7 +995,7 @@ mod tests {
|
|||||||
let mut set = HashSet::new();
|
let mut set = HashSet::new();
|
||||||
let custom = PathBuf::from("E:/wk-test-external-dir");
|
let custom = PathBuf::from("E:/wk-test-external-dir");
|
||||||
set.insert(custom.clone());
|
set.insert(custom.clone());
|
||||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
|
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||||
// 精确命中 + 子路径 starts_with 命中
|
// 精确命中 + 子路径 starts_with 命中
|
||||||
assert!(allowed.is_authorized(&custom));
|
assert!(allowed.is_authorized(&custom));
|
||||||
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
|
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
|
||||||
@@ -978,7 +1006,7 @@ mod tests {
|
|||||||
fn test_allowed_dirs_unauthorized_rejected() {
|
fn test_allowed_dirs_unauthorized_rejected() {
|
||||||
let mut set = HashSet::new();
|
let mut set = HashSet::new();
|
||||||
set.insert(PathBuf::from("E:/wk-test-authorized"));
|
set.insert(PathBuf::from("E:/wk-test-authorized"));
|
||||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
|
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
|
||||||
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
|
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
|
||||||
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
|
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
|
||||||
}
|
}
|
||||||
@@ -1007,7 +1035,7 @@ mod tests {
|
|||||||
fn test_allowed_dirs_session_authorized() {
|
fn test_allowed_dirs_session_authorized() {
|
||||||
let mut session = HashSet::new();
|
let mut session = HashSet::new();
|
||||||
session.insert(PathBuf::from("E:/wk-temp-session"));
|
session.insert(PathBuf::from("E:/wk-temp-session"));
|
||||||
let allowed = AllowedDirs { persistent: HashSet::new(), session };
|
let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() };
|
||||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt")));
|
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1018,7 +1046,7 @@ mod tests {
|
|||||||
persistent.insert(PathBuf::from("E:/wk-persist"));
|
persistent.insert(PathBuf::from("E:/wk-persist"));
|
||||||
let mut session = HashSet::new();
|
let mut session = HashSet::new();
|
||||||
session.insert(PathBuf::from("E:/wk-session"));
|
session.insert(PathBuf::from("E:/wk-session"));
|
||||||
let allowed = AllowedDirs { persistent, session };
|
let allowed = AllowedDirs { persistent, session, once: HashSet::new() };
|
||||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a")));
|
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a")));
|
||||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b")));
|
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b")));
|
||||||
assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c")));
|
assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c")));
|
||||||
@@ -1034,7 +1062,7 @@ mod tests {
|
|||||||
let mut persistent = HashSet::new();
|
let mut persistent = HashSet::new();
|
||||||
// 用户误把整个 C:\ 加入白名单
|
// 用户误把整个 C:\ 加入白名单
|
||||||
persistent.insert(PathBuf::from("C:\\"));
|
persistent.insert(PathBuf::from("C:\\"));
|
||||||
let allowed = AllowedDirs { persistent, session: HashSet::new() };
|
let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() };
|
||||||
// System32 应被黑名单拒(尽管 C:\ 在白名单)
|
// System32 应被黑名单拒(尽管 C:\ 在白名单)
|
||||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam")));
|
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam")));
|
||||||
}
|
}
|
||||||
@@ -1044,7 +1072,7 @@ mod tests {
|
|||||||
fn test_blacklist_windows_program_files() {
|
fn test_blacklist_windows_program_files() {
|
||||||
let mut persistent = HashSet::new();
|
let mut persistent = HashSet::new();
|
||||||
persistent.insert(PathBuf::from("C:\\"));
|
persistent.insert(PathBuf::from("C:\\"));
|
||||||
let allowed = AllowedDirs { persistent, session: HashSet::new() };
|
let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() };
|
||||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe")));
|
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1100,7 +1128,7 @@ mod tests {
|
|||||||
fn test_check_path_session_hit() {
|
fn test_check_path_session_hit() {
|
||||||
let mut session = HashSet::new();
|
let mut session = HashSet::new();
|
||||||
session.insert(PathBuf::from("E:/wk-session"));
|
session.insert(PathBuf::from("E:/wk-session"));
|
||||||
let allowed = AllowedDirs { persistent: HashSet::new(), session };
|
let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() };
|
||||||
match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) {
|
match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) {
|
||||||
PathAuthDecision::Authorized => {}
|
PathAuthDecision::Authorized => {}
|
||||||
other => panic!("session 命中应 Authorized, got {:?}", other),
|
other => panic!("session 命中应 Authorized, got {:?}", other),
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ export const aiApi = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
|
* F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
|
||||||
* decision: 'once'(仅本次,会话级)/ 'always'(未来都允许,持久化)/ 'deny'(拒绝)。
|
* decision: 'once'(本次单次,执行后清)/ 'session'(当前会话,切会话清)/ 'always'(始终,持久化)/ 'deny'(拒绝)。
|
||||||
* 后端 remove pending → 写授权目录(session/persistent)→ 执行工具 → try_continue 恢复 loop。
|
* 后端 remove pending → 写授权目录(once/session/persistent)→ 执行工具 → try_continue 恢复 loop。
|
||||||
*/
|
*/
|
||||||
authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'): Promise<string> {
|
authorizeDir(toolCallId: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<string> {
|
||||||
return invoke('ai_authorize_dir', { toolCallId, decision })
|
return invoke('ai_authorize_dir', { toolCallId, decision })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ export type AiChatEvent = ({
|
|||||||
type: 'AiToolAutoApproved'; id: string; tool: string; dir: string
|
type: 'AiToolAutoApproved'; id: string; tool: string; dir: string
|
||||||
} | {
|
} | {
|
||||||
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分
|
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分
|
||||||
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/always/deny)vs 'risk'(普通 RiskLevel 审批,
|
// 'path'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批,
|
||||||
// 走 approve/reject)。后端阶段3a 未在 wire 上给该事件加 kind(老 path 挂起仍走独立
|
// 走 approve/reject)。后端阶段3a 未在 wire 上给该事件加 kind(老 path 挂起仍走独立
|
||||||
// AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk',
|
// AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk',
|
||||||
// path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。
|
// path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。
|
||||||
@@ -387,7 +387,7 @@ export type AiChatEvent = ({
|
|||||||
type: 'AiMaxRoundsReached'
|
type: 'AiMaxRoundsReached'
|
||||||
} | {
|
} | {
|
||||||
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
|
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
|
||||||
// 前端弹窗三选项:"仅本次"(session)/"未来都允许"(persistent)/"拒绝" → ai_authorize_dir IPC。
|
// 前端弹窗四选项:"本次"(once 执行后清)/"当前会话"(session 切会话清)/"始终放行"(persistent)/"拒绝" → ai_authorize_dir IPC。
|
||||||
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
|
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
|
||||||
} | {
|
} | {
|
||||||
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
|
||||||
@@ -544,8 +544,8 @@ export interface AiToolCallInfo {
|
|||||||
diff?: string
|
diff?: string
|
||||||
/**
|
/**
|
||||||
* path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。
|
* path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。
|
||||||
* - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/always/deny 三选项,
|
* - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/session/always/deny 四选项,
|
||||||
* 调 aiApi.authorizeDir(once 写会话临时授权 / always 写持久白名单 / deny 工具返 Err)。
|
* 调 aiApi.authorizeDir(once 写 once 层单次执行后清 / session 写会话临时 / always 写持久白名单 / deny 工具返 Err)。
|
||||||
* - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项,
|
* - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项,
|
||||||
* 调 aiApi.approve(一次性)。
|
* 调 aiApi.approve(一次性)。
|
||||||
* undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。
|
* undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
后端挂起 loop 等用户决定)。阶段1 改遍历 pendingDirAuths(数组)——同轮多个文件工具落不同
|
后端挂起 loop 等用户决定)。阶段1 改遍历 pendingDirAuths(数组)——同轮多个文件工具落不同
|
||||||
未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖。
|
未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖。
|
||||||
条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。
|
条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。
|
||||||
每项独立三按钮:点"仅本次" → ai_authorize_dir(decision=once, 写会话临时授权)→ 后端执行工具 + try_continue。
|
每项独立四按钮:点"本次" → ai_authorize_dir(decision=once, 写 once 层单次授权,执行后清)→ 后端执行工具 + try_continue。
|
||||||
点"未来都允许" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。
|
点"当前会话" → ai_authorize_dir(decision=session, 写 session 层会话授权,切会话清)→ 执行 + 续 loop。
|
||||||
|
点"始终放行" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。
|
||||||
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
|
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
|
||||||
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
|
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
|
||||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||||
@@ -19,6 +20,11 @@
|
|||||||
:disabled="actingIds.has(item.id)"
|
:disabled="actingIds.has(item.id)"
|
||||||
@click="handleAuthorize(item.id, 'once')"
|
@click="handleAuthorize(item.id, 'once')"
|
||||||
>{{ $t('aiChat.dirAuthOnce') }}</button>
|
>{{ $t('aiChat.dirAuthOnce') }}</button>
|
||||||
|
<button
|
||||||
|
class="ai-dir-auth-btn ai-dir-auth-btn--session"
|
||||||
|
:disabled="actingIds.has(item.id)"
|
||||||
|
@click="handleAuthorize(item.id, 'session')"
|
||||||
|
>{{ $t('aiChat.dirAuthSession') }}</button>
|
||||||
<button
|
<button
|
||||||
class="ai-dir-auth-btn ai-dir-auth-btn--always"
|
class="ai-dir-auth-btn ai-dir-auth-btn--always"
|
||||||
:disabled="actingIds.has(item.id)"
|
:disabled="actingIds.has(item.id)"
|
||||||
@@ -74,7 +80,7 @@ const actingIds = reactive(new Set<string>())
|
|||||||
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
|
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
|
||||||
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
|
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
|
||||||
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
|
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
|
||||||
async function handleAuthorize(id: string, decision: 'once' | 'always' | 'deny'): Promise<void> {
|
async function handleAuthorize(id: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<void> {
|
||||||
if (actingIds.has(id)) return
|
if (actingIds.has(id)) return
|
||||||
actingIds.add(id)
|
actingIds.add(id)
|
||||||
try {
|
try {
|
||||||
@@ -148,6 +154,12 @@ watch(() => pendingDirAuths.value, (items) => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.ai-dir-auth-btn--once:hover:not(:disabled) { filter: brightness(1.1); }
|
.ai-dir-auth-btn--once:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
|
.ai-dir-auth-btn--session {
|
||||||
|
background: color-mix(in srgb, var(--df-accent) 15%, transparent);
|
||||||
|
color: var(--df-accent);
|
||||||
|
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
|
||||||
|
}
|
||||||
|
.ai-dir-auth-btn--session:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
.ai-dir-auth-btn--always {
|
.ai-dir-auth-btn--always {
|
||||||
background: var(--df-warning);
|
background: var(--df-warning);
|
||||||
color: #000;
|
color: #000;
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ const _pendingApprovalIds = new Set<string>()
|
|||||||
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。
|
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。
|
||||||
*
|
*
|
||||||
* path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC——
|
* path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC——
|
||||||
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/always/deny。
|
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/session/always/deny。
|
||||||
* once=仅本次会话授权 / always=写持久白名单 / deny=拒绝。
|
* once=本次单次(执行后清) / session=当前会话(切会话清) / always=写持久白名单 / deny=拒绝。
|
||||||
* - kind='risk'(普通 RiskLevel 审批,默认):调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。
|
* - kind='risk'(普通 RiskLevel 审批,默认):调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。
|
||||||
* approved=true→放行执行,approved=false→拒绝。
|
* approved=true→放行执行,approved=false→拒绝。
|
||||||
* 入口据 findToolCall(toolCallId).kind 判定,kind 缺省走 risk 老路径(兼容非统一开关 / 老 toolCall)。
|
* 入口据 findToolCall(toolCallId).kind 判定,kind 缺省走 risk 老路径(兼容非统一开关 / 老 toolCall)。
|
||||||
@@ -40,16 +40,16 @@ const _pendingApprovalIds = new Set<string>()
|
|||||||
* 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。
|
* 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。
|
||||||
*
|
*
|
||||||
* @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。
|
* @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。
|
||||||
* @param decision path 类专用('once'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
|
* @param decision path 类专用('once'|'session'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
|
||||||
*/
|
*/
|
||||||
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'always' | 'deny') {
|
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
|
||||||
// F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
|
// F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
|
||||||
if (_pendingApprovalIds.has(toolCallId)) return
|
if (_pendingApprovalIds.has(toolCallId)) return
|
||||||
_pendingApprovalIds.add(toolCallId)
|
_pendingApprovalIds.add(toolCallId)
|
||||||
try {
|
try {
|
||||||
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
|
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
|
||||||
const tc = findToolCall(toolCallId)
|
const tc = findToolCall(toolCallId)
|
||||||
// path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/always/deny);
|
// path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/session/always/deny);
|
||||||
// 其余(risk/缺省)走 approve(approve/reject)。
|
// 其余(risk/缺省)走 approve(approve/reject)。
|
||||||
const isPathKind = !!tc && tc.kind === 'path'
|
const isPathKind = !!tc && tc.kind === 'path'
|
||||||
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired/AiDirAuthRequired 已 clear,
|
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired/AiDirAuthRequired 已 clear,
|
||||||
@@ -95,7 +95,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
|
|||||||
*
|
*
|
||||||
* path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。
|
* path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。
|
||||||
* 批量操作语义:
|
* 批量操作语义:
|
||||||
* - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权仅本次,
|
* - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权本次单次,
|
||||||
* 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。
|
* 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。
|
||||||
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
|
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。
|
||||||
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
|
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
|
||||||
@@ -106,7 +106,7 @@ async function batchApprove(decision: 'approve' | 'reject') {
|
|||||||
for (const p of items) {
|
for (const p of items) {
|
||||||
try {
|
try {
|
||||||
if (p.kind === 'path') {
|
if (p.kind === 'path') {
|
||||||
// path 类:approve→once(仅本次,不批量写持久白名单), reject→deny
|
// path 类:approve→once(本次单次,不批量写持久白名单), reject→deny
|
||||||
await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny')
|
await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny')
|
||||||
} else {
|
} else {
|
||||||
// risk 类:approve/reject boolean
|
// risk 类:approve/reject boolean
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ export default {
|
|||||||
dirAuthRequired: 'Path authorization required',
|
dirAuthRequired: 'Path authorization required',
|
||||||
dirAuthHint: '{tool} wants to access: {path}',
|
dirAuthHint: '{tool} wants to access: {path}',
|
||||||
dirAuthOnce: 'This time',
|
dirAuthOnce: 'This time',
|
||||||
dirAuthAlways: 'Always allow',
|
dirAuthSession: 'This session',
|
||||||
|
dirAuthAlways: 'Always',
|
||||||
dirAuthDeny: 'Deny',
|
dirAuthDeny: 'Deny',
|
||||||
dirAuthFailed: 'Authorization failed: {msg}',
|
dirAuthFailed: 'Authorization failed: {msg}',
|
||||||
|
|
||||||
|
|||||||
@@ -155,8 +155,9 @@ export default {
|
|||||||
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
|
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
|
||||||
dirAuthRequired: '需要路径授权',
|
dirAuthRequired: '需要路径授权',
|
||||||
dirAuthHint: '{tool} 想访问:{path}',
|
dirAuthHint: '{tool} 想访问:{path}',
|
||||||
dirAuthOnce: '仅本次',
|
dirAuthOnce: '本次',
|
||||||
dirAuthAlways: '未来都允许',
|
dirAuthSession: '当前会话',
|
||||||
|
dirAuthAlways: '始终放行',
|
||||||
dirAuthDeny: '拒绝',
|
dirAuthDeny: '拒绝',
|
||||||
dirAuthFailed: '授权失败:{msg}',
|
dirAuthFailed: '授权失败:{msg}',
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user