新增: idea source 消息级溯源 + workspace_root 去固定根 + run_command 偏好修复

idea source 结构化(消息级溯源闭环 — 灵感来源 conv_msg:{id}):
- 新建 idea_source.rs(IDEA_SOURCE_AUTO_FILL_ENABLED + maybe_fill_idea_source 保守五条件:
  开关/create_idea/source 空(不覆盖 AI 填)/message_id 非 None/result.id)
- audit/mod low_risk + chat ai_approve 双执行路径接补全(create_idea Medium 审批)

workspace_root 去固定根(F-260619-03 收尾):
- is_authorized 删 workspace_root 固定放行(代码硬编码),改走 persistent/session 白名单
- default_with_root 保留(默认初始 persistent,首次免授权行为不变)
- 用户从白名单删工程根后也需授权(动态白名单完整语义)

run_command 偏好修复(db 分析:591ff4a0 run_command 31/56=55% 严重偏好):
- P1 描述收紧(明确仅命令执行 + 负向引导:读取 read_file/编辑 patch_file/列目录 list_directory/搜索 search_files)
- P2 ToolDomain::Exec(run_command 独立 domain,仅 Debug 含 Exec,Code/File/Search 不含)
- P3 注册顺序(run_command 放 File 层最后)+ DEBUG_RULES 加命令执行关键词

自验: df-ai 228(intent Exec)+ devflow 169(idea source)+ workspace EXIT 0
This commit is contained in:
2026-06-20 04:08:10 +08:00
parent 7911bc292c
commit e4c2e310a5
6 changed files with 496 additions and 90 deletions

View File

@@ -119,6 +119,13 @@ const DEBUG_RULES: &[Rule] = &[
Rule { kw: "", weight: 0.5 }, Rule { kw: "", weight: 0.5 },
Rule { kw: "panic", weight: 0.9 }, Rule { kw: "panic", weight: 0.9 },
Rule { kw: "traceback", weight: 0.9 }, Rule { kw: "traceback", weight: 0.9 },
// 命令执行场景关键词run_command 入口引导):用户明确"运行/执行/构建/测试/跑"
// 才该走 Exec domain(run_command)。中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。
Rule { kw: "运行", weight: 0.7 },
Rule { kw: "执行", weight: 0.7 },
Rule { kw: "构建", weight: 0.7 },
Rule { kw: "测试", weight: 0.7 },
Rule { kw: "", weight: 0.6 },
]; ];
const FILE_RULES: &[Rule] = &[ const FILE_RULES: &[Rule] = &[
@@ -225,8 +232,10 @@ const GENERIC_GROUP: &[IntentGroup] = &[
pub enum ToolDomain { pub enum ToolDomain {
/// 数据/业务:项目/任务/灵感/工作流/回收站 /// 数据/业务:项目/任务/灵感/工作流/回收站
Data, Data,
/// 文件:读写/patch/列目录/搜索/命令 /// 文件:读写/patch/列目录/搜索(不含命令执行)
File, File,
/// 命令执行run_commandshell 命令,独立 domain 防止被泛 File 意图带出)
Exec,
/// 网络HTTP /// 网络HTTP
Http, Http,
} }
@@ -261,12 +270,15 @@ impl ToolDomain {
"patch_file", "patch_file",
"list_directory", "list_directory",
"search_files", "search_files",
"run_command",
"file_info", "file_info",
"append_file", "append_file",
"delete_file", "delete_file",
"rename_file", "rename_file",
], ],
// Exec domain 独立:run_command 从 File 移出。
// 意图侧仅 Debug 默认带 Exec用户明确"运行/测试/构建"
// Code/File/Search 不带 → 减少 LLM 对 run_command 的偏好暴露。
ToolDomain::Exec => &["run_command"],
ToolDomain::Http => &["http_request"], ToolDomain::Http => &["http_request"],
} }
} }
@@ -337,11 +349,13 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
/// ///
/// 设计Code → [file, http]File → [file]Project/Task/Idea → [data] /// 设计Code → [file, http]File → [file]Project/Task/Idea → [data]
/// Http → [http]Search → [file](含 search_filesConversation → []; /// Http → [http]Search → [file](含 search_filesConversation → [];
/// Chat → []Debug → [file, http, data](调试常需读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);Unknown → [](全量)。 /// Chat → []Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);
/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command,
/// Code/File/Search 不含 Exec → 收紧 run_command 暴露面Unknown → [](全量)。
pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> { pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
let domains: &[ToolDomain] = match intent { let domains: &[ToolDomain] = match intent {
Intent::Code => &[ToolDomain::File, ToolDomain::Http], Intent::Code => &[ToolDomain::File, ToolDomain::Http],
Intent::Debug => &[ToolDomain::File, ToolDomain::Http, ToolDomain::Data], Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data],
Intent::File => &[ToolDomain::File], Intent::File => &[ToolDomain::File],
Intent::Project => &[ToolDomain::Data], Intent::Project => &[ToolDomain::Data],
Intent::Task => &[ToolDomain::Data], Intent::Task => &[ToolDomain::Data],
@@ -669,7 +683,9 @@ mod tests {
fn tool_domain_names_match_registry() { fn tool_domain_names_match_registry() {
// 抽样核对与 src-tauri tool_registry 实际注册名一致 // 抽样核对与 src-tauri tool_registry 实际注册名一致
assert!(ToolDomain::File.tools().contains(&"patch_file")); assert!(ToolDomain::File.tools().contains(&"patch_file"));
assert!(ToolDomain::File.tools().contains(&"run_command")); // run_command 已从 File 移出 Exec domain(意图收紧:仅 Debug 默认带 run_command)
assert!(!ToolDomain::File.tools().contains(&"run_command"));
assert_eq!(ToolDomain::Exec.tools(), &["run_command"]);
assert!(ToolDomain::Data.tools().contains(&"advance_task")); assert!(ToolDomain::Data.tools().contains(&"advance_task"));
assert!(ToolDomain::Data.tools().contains(&"run_workflow")); assert!(ToolDomain::Data.tools().contains(&"run_workflow"));
assert!(ToolDomain::Data.tools().contains(&"list_trash")); assert!(ToolDomain::Data.tools().contains(&"list_trash"));
@@ -838,9 +854,9 @@ mod tests {
} }
#[test] #[test]
fn filter_debug_subset_spans_three_domains_complete() { fn filter_debug_subset_spans_four_domains_complete() {
// 对抗(多 domain):Debug subset 跨 File + Http + Data domain, // 对抗(多 domain):Debug subset 跨 File + Exec + Http + Data domain,
// 必须同条消息里能同时命中个 domain 的工具(防 domain 漏挂) // 必须同条消息里能同时命中个 domain 的工具(防 domain 漏挂)
let all = vec![ let all = vec![
// Data domain // Data domain
tool_def("list_tasks"), tool_def("list_tasks"),
@@ -849,6 +865,8 @@ mod tests {
// File domain // File domain
tool_def("read_file"), tool_def("read_file"),
tool_def("patch_file"), tool_def("patch_file"),
// Exec domain
tool_def("run_command"),
// Http domain // Http domain
tool_def("http_request"), tool_def("http_request"),
]; ];
@@ -856,8 +874,27 @@ mod tests {
let names: Vec<String> = out.iter().map(|d| d.function.name.clone()).collect(); let names: Vec<String> = out.iter().map(|d| d.function.name.clone()).collect();
assert!(names.contains(&"list_tasks".to_string()), "Debug 必含 Data(list_tasks), 防 CR-25 🟡-1 丢 Data"); assert!(names.contains(&"list_tasks".to_string()), "Debug 必含 Data(list_tasks), 防 CR-25 🟡-1 丢 Data");
assert!(names.contains(&"read_file".to_string()), "Debug 必含 File"); assert!(names.contains(&"read_file".to_string()), "Debug 必含 File");
assert!(names.contains(&"run_command".to_string()), "Debug 必含 Exec(run_command)");
assert!(names.contains(&"http_request".to_string()), "Debug 必含 Http"); assert!(names.contains(&"http_request".to_string()), "Debug 必含 Http");
assert_eq!(names.len(), 6, " domain 工具全保留, 实际: {:?}", names); assert_eq!(names.len(), 7, " domain 工具全保留, 实际: {:?}", names);
}
#[test]
fn run_command_only_exposed_for_debug_intent() {
// 核心修复断言:run_command 仅 Debug 暴露。
// Code/File/Search 意图 subset 不含 Exec domain → 不含 run_command。
for intent in [Intent::Code, Intent::File, Intent::Search] {
let s = tool_subset_for(&intent);
assert!(
!s.contains(&"run_command"),
"Intent {:?} subset 不应含 run_command(仅 Debug 暴露), 实际: {:?}",
intent,
s
);
}
// Debug 必含 run_command(Exec domain)
let dbg = tool_subset_for(&Intent::Debug);
assert!(dbg.contains(&"run_command"), "Debug 必含 run_command");
} }
#[test] #[test]
@@ -893,11 +930,12 @@ mod tests {
]; ];
for intent in all_intents { for intent in all_intents {
let subset = tool_subset_for(&intent); let subset = tool_subset_for(&intent);
// 全 registry 工具名( domain 并集) // 全 registry 工具名( domain 并集:Data + File + Exec + Http)
let registry: std::collections::HashSet<&str> = ToolDomain::Data let registry: std::collections::HashSet<&str> = ToolDomain::Data
.tools() .tools()
.iter() .iter()
.chain(ToolDomain::File.tools().iter()) .chain(ToolDomain::File.tools().iter())
.chain(ToolDomain::Exec.tools().iter())
.chain(ToolDomain::Http.tools().iter()) .chain(ToolDomain::Http.tools().iter())
.copied() .copied()
.collect(); .collect();

View File

@@ -0,0 +1,328 @@
//! F-260619-04 P2(方案 B): 灵感来源(create_idea)消息级溯源补全。
//!
//! ## 背景
//! P1 把知识/审计的 `source_ref` 升级到消息级(`conv_msg:{id}`),但**灵感 `create_idea`
//! 漏了**:create_idea handler 是 `Fn(Value)→result` 无 message_id 上下文,source 由 AI
//! 自由填(`args["source"]`)。AI 多数场景不填(空),致灵感丢失溯源——查不到从哪条对话长出来。
//!
//! ## 方案 B(最小,低侵入 — 不改 AiToolHandler 接口)
//! 工具执行后(process_tool_calls / 审批执行路径)拿到 handler 返回的 result + AI 入参 args,
//! 检测 `tool_name == "create_idea"`:
//! - 仅当 AI 未填 source(空 / null / 缺字段)且 message_id 可得 → 补 `source = conv_msg:{message_id}`
//! - AI 已填(非空)→ **不覆盖**(保留 AI 有意义的自由文本,如"用户口述"/"xxx 文档")
//! - message_id 缺失(None,异常路径/老数据)→ **不补**(无源可溯,保持原值,不伪造)
//!
//! ## 保守原则
//! 仅 create_idea 专用(其他工具不碰) / 仅 source 空时补(不覆盖 AI 填) / 无 message_id 不补。
//! 常量开关 `IDEA_SOURCE_AUTO_FILL_ENABLED` 可一键关闭(易迭代,出问题降级)。
use std::sync::Arc;
use df_storage::crud::IdeaRepo;
use df_storage::db::Database;
/// 一键开关:是否启用 create_idea source 自动补全。出问题改 false 即降级为 noop(不补)。
const IDEA_SOURCE_AUTO_FILL_ENABLED: bool = true;
/// 从 AI 入参 args 取 source,返回是否「AI 未填」(空 / null / 缺字段)。
///
/// 独立纯函数(便于单测):判定逻辑收敛一处,调用方与测试共享同一语义。
/// - `{"source": null}` / `{"source": ""}` / 缺 source 键 → true(未填,应补)
/// - `{"source": "用户口述"}` → false(AI 已填,不覆盖)
fn is_source_unfilled(args: &serde_json::Value) -> bool {
match args.get("source") {
None => true,
Some(serde_json::Value::Null) => true,
Some(serde_json::Value::String(s)) if s.trim().is_empty() => true,
_ => false,
}
}
/// 从 create_idea handler 返回的 result 取 idea_id(定位刚创建的灵感行)。
///
/// handler 返回 `{ "id": "...", "title": "...", "status": "draft" }`(tool_registry.rs create_idea)。
/// id 字段即 idea 表主键,用于 `IdeaRepo.update_field(id, "source", ...)`。
fn extract_idea_id(result: &serde_json::Value) -> Option<String> {
result.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())
}
/// 灵感来源消息级溯源补全(方案 B 核心)。
///
/// **保守**:仅在以下条件全部满足时补 source = `conv_msg:{message_id}`:
/// 1. `IDEA_SOURCE_AUTO_FILL_ENABLED == true`(一键开关)
/// 2. `tool_name == "create_idea"`(专用,其他工具不碰)
/// 3. `args.source` 未填(空 / null / 缺字段)—— AI 已填则**不覆盖**
/// 4. `message_id` 为 `Some`(无源可溯的异常/老数据不补,不伪造)
/// 5. result 含 `id`(idea_id,定位刚创建的灵感行)
///
/// 补全失败(库写失败等)仅 `tracing::warn` 不传播——溯源补全是 best-effort 增益,
/// 不应阻断工具已成功的执行流程(灵感已创建,source 缺失只是少了溯源,非业务失败)。
///
/// 单点逻辑(本函数)+ 多调用点(process_tool_calls 各执行路径 + 审批执行 chat.rs):
/// 调用点只管传 (tool_name, args, result, message_id),判定/补全/容错全在本函数。
pub async fn maybe_fill_idea_source(
db: &Arc<Database>,
tool_name: &str,
args: &serde_json::Value,
result: &serde_json::Value,
message_id: Option<&str>,
) {
// 条件 1: 一键开关
if !IDEA_SOURCE_AUTO_FILL_ENABLED {
return;
}
// 条件 2: 仅 create_idea 专用
if tool_name != "create_idea" {
return;
}
// 条件 3: AI 未填 source 才补(AI 已填不覆盖)
if !is_source_unfilled(args) {
return;
}
// 条件 4: message_id 缺失不补(无源可溯,不伪造)
let msg_id = match message_id {
Some(id) if !id.is_empty() => id,
_ => return,
};
// 条件 5: result 含 idea_id(定位刚创建的灵感行)
let idea_id = match extract_idea_id(result) {
Some(id) => id,
None => {
tracing::warn!(
tool = tool_name,
"[idea-source] create_idea result 缺 id 字段,无法定位灵感行补 source(降级跳过)"
);
return;
}
};
let source_val = format!("conv_msg:{}", msg_id);
let repo = IdeaRepo::new(db);
match repo.update_field(&idea_id, "source", &source_val).await {
Ok(true) => {
tracing::info!(
idea_id = %idea_id,
message_id = %msg_id,
"[idea-source] 补全灵感来源消息级溯源: source = conv_msg:{}",
msg_id
);
}
Ok(false) => {
// update_field 返 false = 影响行数 0(idea_id 不存在,异常,但灵感刚创建理论上必存在)
tracing::warn!(
idea_id = %idea_id,
"[idea-source] update_field 影响 0 行(idea_id 不存在?);source 未补,灵感已创建不受影响"
);
}
Err(e) => {
// best-effort:溯源补全失败不阻断工具成功流程,仅 warn
tracing::warn!(
idea_id = %idea_id,
error = %e,
"[idea-source] 补全灵感来源失败(降级跳过,灵感已创建不受影响)"
);
}
}
}
// ============================================================
// 单元测试 — 判定纯函数 + 补全全链路(内存 DB)
// ============================================================
// ============================================================
// 测试辅助(仅 cfg(test))— 内存 DB + 插入 IdeaRecord fixture
// ============================================================
// 独立 #[cfg(test)] 子模块:供本文件 tests 与同 crate 其他测试复用(防 DRY)。
// 不暴露到非测试构建(避免误用内存 DB 做 prod 路径)。
#[cfg(test)]
mod idea_source_test_helpers {
use std::sync::Arc;
use df_storage::crud::IdeaRepo;
use df_storage::db::Database;
use df_storage::models::IdeaRecord;
/// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。
///
/// `source`: None → 插 source=NULL(模拟 AI 未填);Some(s) → 插已填值。
/// 其余字段用合理默认(title/description/status=draft/priority=1)。
pub async fn idea_source_fixture_record(id: &str, source: Option<&str>) -> Arc<Database> {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = IdeaRepo::new(&db);
let now = df_types::now_millis().to_string();
repo.insert(IdeaRecord {
id: id.to_string(),
title: format!("fixture-{}", id),
description: String::new(),
status: "draft".to_string(),
priority: 1,
score: None,
tags: None,
source: source.map(|s| s.to_string()),
promoted_to: None,
ai_analysis: None,
scores: None,
created_at: now.clone(),
updated_at: now,
})
.await
.expect("insert fixture idea");
Arc::new(db)
}
}
#[cfg(test)]
mod tests {
use super::idea_source_test_helpers::idea_source_fixture_record;
use super::*;
// ---------- is_source_unfilled 纯函数 ----------
#[test]
fn source_missing_key_is_unfilled() {
let args = serde_json::json!({ "title": "t" });
assert!(is_source_unfilled(&args));
}
#[test]
fn source_null_is_unfilled() {
let args = serde_json::json!({ "title": "t", "source": null });
assert!(is_source_unfilled(&args));
}
#[test]
fn source_empty_string_is_unfilled() {
let args = serde_json::json!({ "title": "t", "source": "" });
assert!(is_source_unfilled(&args));
}
#[test]
fn source_whitespace_only_is_unfilled() {
let args = serde_json::json!({ "title": "t", "source": " " });
assert!(is_source_unfilled(&args));
}
#[test]
fn source_non_empty_is_filled() {
let args = serde_json::json!({ "title": "t", "source": "用户口述" });
assert!(!is_source_unfilled(&args));
}
// ---------- extract_idea_id ----------
#[test]
fn extract_idea_id_from_result() {
let result = serde_json::json!({ "id": "idea-1", "title": "t", "status": "draft" });
assert_eq!(extract_idea_id(&result).as_deref(), Some("idea-1"));
}
#[test]
fn extract_idea_id_missing_returns_none() {
let result = serde_json::json!({ "title": "t" });
assert!(extract_idea_id(&result).is_none());
}
// ---------- maybe_fill_idea_source 全链路(内存 DB)----------
/// create_idea + source 空 + message_id 有 → 补 conv_msg:{id}
#[tokio::test]
async fn fills_source_when_unfilled_and_message_id_present() {
let db = idea_source_fixture_record("idea-1", None).await;
let args = serde_json::json!({ "title": "t" }); // source 缺
let result = serde_json::json!({ "id": "idea-1", "title": "t", "status": "draft" });
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await;
let repo = IdeaRepo::new(&db);
let rec = repo.get_by_id("idea-1").await.unwrap().expect("idea-1 存在");
assert_eq!(rec.source.as_deref(), Some("conv_msg:msg-42"));
}
/// create_idea + AI 已填 source → 不覆盖
#[tokio::test]
async fn does_not_overwrite_when_ai_filled_source() {
let db = idea_source_fixture_record("idea-2", Some("用户口述")).await;
let args = serde_json::json!({ "title": "t", "source": "用户口述" });
let result = serde_json::json!({ "id": "idea-2", "title": "t", "status": "draft" });
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await;
let repo = IdeaRepo::new(&db);
let rec = repo.get_by_id("idea-2").await.unwrap().expect("idea-2 存在");
assert_eq!(rec.source.as_deref(), Some("用户口述"), "AI 已填 source 不被覆盖");
}
/// message_id 缺失(None)→ 不补
#[tokio::test]
async fn does_not_fill_when_message_id_none() {
let db = idea_source_fixture_record("idea-3", None).await;
let args = serde_json::json!({ "title": "t" });
let result = serde_json::json!({ "id": "idea-3", "title": "t", "status": "draft" });
maybe_fill_idea_source(&db, "create_idea", &args, &result, None).await;
let repo = IdeaRepo::new(&db);
let rec = repo.get_by_id("idea-3").await.unwrap().expect("idea-3 存在");
assert!(rec.source.is_none(), "无 message_id 不补 source");
}
/// message_id 空串("")→ 不补(等同 None 语义,防伪造)
#[tokio::test]
async fn does_not_fill_when_message_id_empty_string() {
let db = idea_source_fixture_record("idea-3b", None).await;
let args = serde_json::json!({ "title": "t" });
let result = serde_json::json!({ "id": "idea-3b", "title": "t", "status": "draft" });
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("")).await;
let repo = IdeaRepo::new(&db);
let rec = repo.get_by_id("idea-3b").await.unwrap().expect("idea-3b 存在");
assert!(rec.source.is_none(), "空串 message_id 不补 source");
}
/// 非 create_idea 工具 → 不碰(其他工具不受影响)
#[tokio::test]
async fn does_not_touch_other_tools() {
let db = idea_source_fixture_record("idea-4", None).await;
let args = serde_json::json!({ "title": "t" });
let result = serde_json::json!({ "id": "idea-4" });
maybe_fill_idea_source(&db, "write_file", &args, &result, Some("msg-42")).await;
let repo = IdeaRepo::new(&db);
let rec = repo.get_by_id("idea-4").await.unwrap().expect("idea-4 存在");
assert!(rec.source.is_none(), "非 create_idea 不碰 source");
}
/// result 缺 id 字段 → 不补(降级 warn,不 panic)
#[tokio::test]
async fn does_not_fill_when_result_missing_id() {
let db = idea_source_fixture_record("idea-5", None).await;
let args = serde_json::json!({ "title": "t" });
let result = serde_json::json!({ "title": "t", "status": "draft" }); // 无 id
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await;
let repo = IdeaRepo::new(&db);
let rec = repo.get_by_id("idea-5").await.unwrap().expect("idea-5 存在");
assert!(rec.source.is_none(), "result 缺 id 时不补,保持原值");
}
/// idea_id 不存在于库 → 不 panic(0 行影响 warn)
#[tokio::test]
async fn does_not_panic_when_idea_id_not_in_db() {
let db = idea_source_fixture_record("idea-6", None).await;
let args = serde_json::json!({ "title": "t" });
// result 指向不存在的 idea-x
let result = serde_json::json!({ "id": "idea-nonexistent", "title": "t", "status": "draft" });
// 不应 panic,仅 warn
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await;
// idea-6 原值不变
let repo = IdeaRepo::new(&db);
let rec = repo.get_by_id("idea-6").await.unwrap().expect("idea-6 存在");
assert!(rec.source.is_none());
}
}

View File

@@ -126,6 +126,12 @@ pub(super) use cache::{find_cached_high_risk_result, PENDING_APPROVAL_PLACEHOLDE
mod data_change; mod data_change;
pub(crate) use data_change::emit_data_changed; pub(crate) use data_change::emit_data_changed;
// idea_source(audit/idea_source.rs):F-260619-04 P2(方案 B) 灵感来源消息级溯源补全。
// create_idea 工具执行后,若 AI 未填 source 且有 message_id → 补 conv_msg:{id}(低侵入,不改 handler 接口)。
// pub(crate) use 供本文件 process_tool_calls + chat.rs 审批执行路径调用(单点逻辑,多调用点)。
mod idea_source;
pub(crate) use idea_source::maybe_fill_idea_source;
/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。 /// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。
/// ///
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。 /// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
@@ -488,7 +494,8 @@ pub(crate) async fn process_tool_calls(
// push tool_result / audit 在 join_all 后串行回填(持锁,与 Med/High 占位拼接)。 // push tool_result / audit 在 join_all 后串行回填(持锁,与 Med/High 占位拼接)。
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort // join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
if !low_risk.is_empty() { if !low_risk.is_empty() {
let results: Vec<(ToolCallDraft, Result<String, String>)> = // 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
let results: Vec<(ToolCallDraft, serde_json::Value, Result<String, String>)> =
futures::future::join_all(low_risk.into_iter().map(|(draft, args)| { futures::future::join_all(low_risk.into_iter().map(|(draft, args)| {
let tools = tools_arc.clone(); let tools = tools_arc.clone();
let app_clone = app_handle.clone(); let app_clone = app_handle.clone();
@@ -505,7 +512,7 @@ pub(crate) async fn process_tool_calls(
// AR-11方案A数据变更工具执行成功后 emit df-data-changed, // AR-11方案A数据变更工具执行成功后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit见 emit_data_changed // 前端 store listen 刷新列表(仅命中映射的工具 emit见 emit_data_changed
emit_data_changed(&app_clone, &draft.name); emit_data_changed(&app_clone, &draft.name);
(draft, Ok(val.to_string())) (draft, val.clone(), Ok(val.to_string()))
} }
Err(e) => { Err(e) => {
// AR-6定向BLow 工具失败不 emit AiError。 // AR-6定向BLow 工具失败不 emit AiError。
@@ -519,18 +526,25 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(err_msg.clone()), result: serde_json::Value::String(err_msg.clone()),
conversation_id: Some(conv_clone), conversation_id: Some(conv_clone),
}); });
(draft, Err(err_msg)) (draft, serde_json::Value::Null, Err(err_msg))
} }
} }
} }
})).await; })).await;
// 串行回填 tool_result + 审计(持 session 锁) // 串行回填 tool_result + 审计(持 session 锁)
for (draft, outcome) in results { for (draft, raw_result, outcome) in results {
let (status, content) = match outcome { let (status, content) = match outcome {
Ok(c) => ("completed", c), Ok(c) => ("completed", c),
Err(c) => ("failed", c), Err(c) => ("failed", c),
}; };
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全(仅 source 空 + 有 message_id)。
// 注:低风险路径目前 create_idea 不会进(Medium→pending),此处为防御/未来若调级别覆盖。
// 仅 Ok(completed) 时补(失败 result 无 idea_id 意义);args 从 draft.args 反解(JSON 原样)。
if status == "completed" {
let args_val = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Null);
maybe_fill_idea_source(db, &draft.name, &args_val, &raw_result, current_message_id).await;
}
// F-260616-09 B 批2:写 per_conv.messages。 // F-260616-09 B 批2:写 per_conv.messages。
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto"), current_message_id).await; audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto"), current_message_id).await;

View File

@@ -500,6 +500,28 @@ pub async fn ai_approve(
}); });
drop(session); drop(session);
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
// create_idea 是 Medium risk → 经审批执行(本路径),非 process_tool_calls 内联执行,
// 故补全点放此(工具已 executed,可拿到 result.id + args.source)。
// message_id 从 per_conv.messages 取末条 assistant id(同 process_tool_calls 的 current_message_id 语义);
// assistant_with_tools 消息持久驻留 messages,审批时仍可读。
// 仅 executed(成功)且 create_idea 才补;失败/其他工具 noop(helper 内判定,不污染本路径主流程)。
if audit_status == "executed" {
let message_id = {
let session = state.ai_session.lock().await;
conv_id.as_deref().and_then(|cid| {
session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id())
})
};
super::super::audit::maybe_fill_idea_source(
&state.db,
&approval.tool_name,
&approval.arguments,
&result_val,
message_id.as_deref(),
).await;
}
// 审批执行结果立即落库,不依赖后续 agentic loop(避免 loop 异常退出时丢失真实结果) // 审批执行结果立即落库,不依赖后续 agentic loop(避免 loop 异常退出时丢失真实结果)
// 含 recovered 积压审批——switch 时已 restore_from_messages 载完整历史,messages 非空, // 含 recovered 积压审批——switch 时已 restore_from_messages 载完整历史,messages 非空,
// save 不污染老对话;原 if !recovered 守卫前提不成立已移除。 // save 不污染老对话;原 if !recovered 守卫前提不成立已移除。

View File

@@ -881,73 +881,6 @@ fn register_file_tools(
// run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环), // run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环),
// 故该工具闭包不捕获 allowed_dirs,维持原行为。 // 故该工具闭包不捕获 allowed_dirs,维持原行为。
let allowed_dirs = allowed_dirs.clone(); let allowed_dirs = allowed_dirs.clone();
registry.register(
"run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险须人工批准。命令需自包含非交互式避免需用户输入的程序。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
df_ai::ai_tools::object_schema(vec![
("command", "string", true),
("working_dir", "string", false),
("timeout_secs", "integer", false),
]),
RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move {
let command = args["command"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => {
validate_path(d)?;
d.to_string()
}
None => workspace_root().to_string_lossy().to_string(),
};
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
let request = ShellRequest {
command: command.to_string(),
working_dir: Some(working_dir.clone()),
env: HashMap::new(),
timeout_secs: Some(timeout_secs),
shell_type: Default::default(),
};
// F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。
// 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
// 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
let result = execute(request).await.map_err(|e| {
let msg = e.to_string();
if msg.contains("命令执行超时") {
anyhow::anyhow!(
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。",
timeout_secs
)
} else {
e
}
})?;
// 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
const MAX_OUT: usize = 10_000;
let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT);
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
Ok(serde_json::json!({
"command": command,
"working_dir": working_dir,
"exit_code": result.exit_code,
"duration_ms": result.duration_ms,
"stdout": stdout,
"stderr": stderr,
"truncated": stdout_trunc || stderr_trunc,
}))
})),
);
// ── 文件系统 ── // ── 文件系统 ──
registry.register( registry.register(
@@ -1633,6 +1566,76 @@ fn register_file_tools(
}) })
})}, })},
); );
// ── 命令执行run_command 放 File 层最后,降低 LLM 偏好:靠前注册易被 LLM 优先选中,
// 移到末位让 read_file/write_file 等高频文件工具在前run_command 仅命令执行场景才该用) ──
registry.register(
"run_command", "在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file编辑文件用 patch_file/write_file列目录用 list_directory搜索文件名用 search_files——不要用本工具完成这些操作。高风险须人工批准。命令需自包含非交互式避免需用户输入的程序。默认超时 60 秒。",
df_ai::ai_tools::object_schema(vec![
("command", "string", true),
("working_dir", "string", false),
("timeout_secs", "integer", false),
]),
RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move {
let command = args["command"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => {
validate_path(d)?;
d.to_string()
}
None => workspace_root().to_string_lossy().to_string(),
};
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
let request = ShellRequest {
command: command.to_string(),
working_dir: Some(working_dir.clone()),
env: HashMap::new(),
timeout_secs: Some(timeout_secs),
shell_type: Default::default(),
};
// F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。
// 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
// 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
let result = execute(request).await.map_err(|e| {
let msg = e.to_string();
if msg.contains("命令执行超时") {
anyhow::anyhow!(
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。",
timeout_secs
)
} else {
e
}
})?;
// 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
const MAX_OUT: usize = 10_000;
let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT);
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
Ok(serde_json::json!({
"command": command,
"working_dir": working_dir,
"exit_code": result.exit_code,
"duration_ms": result.duration_ms,
"stdout": stdout,
"stderr": stderr,
"truncated": stdout_trunc || stderr_trunc,
}))
})),
);
} }
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条) /// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
@@ -1852,10 +1855,11 @@ mod tests {
"run_workflow", "delete_task", "create_idea", "run_workflow", "delete_task", "create_idea",
"delete_project", "restore_project", "purge_project", "delete_project", "restore_project", "purge_project",
"list_trash", "get_project_count", "get_task_count", "list_trash", "get_project_count", "get_task_count",
// ── file 层 (10) ── // ── file 层 (10) ──run_command 注册顺序已移至末位降低 LLM 偏好,
"run_command", "read_file", "list_directory", // 集合断言经 sort 后与顺序无关,仅守护工具名不漂移)
"write_file", "patch_file", "file_info", "read_file", "list_directory", "write_file",
"append_file", "delete_file", "rename_file", "search_files", "patch_file", "file_info", "append_file",
"delete_file", "rename_file", "search_files", "run_command",
// ── http 层 (1) ── // ── http 层 (1) ──
"http_request", "http_request",
]; ];

View File

@@ -341,7 +341,11 @@ impl AllowedDirs {
Self { persistent: set, session: HashSet::new() } Self { persistent: set, session: HashSet::new() }
} }
/// 路径是否被授权:workspace_root 始终授权 + persistent 或 session 任一 starts_with 命中即放行。 /// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。
///
/// **workspace_root 默认在 persistent**(default_with_root 初始插入,reload_allowed_dirs
/// KV 未配时保持),故首次访问工程根免授权(向后兼容)。用户从白名单删除工程根后,
/// 工程根也需授权(动态白名单完整语义,F-260619-03 收尾:去掉代码硬编码固定放行)。
/// ///
/// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows /// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows
/// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot /// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot
@@ -355,10 +359,6 @@ impl AllowedDirs {
if is_in_system_blacklist(candidate) { if is_in_system_blacklist(candidate) {
return false; return false;
} }
let root = workspace_root_path();
if candidate.starts_with(&root) {
return true;
}
self.persistent.iter().any(|d| candidate.starts_with(d)) self.persistent.iter().any(|d| candidate.starts_with(d))
|| self.session.iter().any(|d| candidate.starts_with(d)) || self.session.iter().any(|d| candidate.starts_with(d))
} }