修复: 全库走查 P0+P1(df-mcp 数据丢失/evaluate 拆 + df-execute probe_pwsh 死缓存/超时/单源 + ai_self_review 注入隔离)
- df-mcp P0: update_idea/project/task 缺省回退 existing(治部分更新清空 title 丢数据)+ P1: evaluate_idea 拆只读(Low)+score_idea(Medium 写),read-only 不再改库 - df-execute P0: probe_pwsh 死缓存(两 OnceLock 合并 PWSH_CACHE 单源,治 Windows 永走 PS5)+ P1: probe_pwsh 3s 超时防挂起 + detect_shell 复用 shell.rs 单源(探测与执行对齐) - df-nodes P1: ai_self_review prompt 注入隔离(truncate + XML 标签 <task_output> 数据/指令隔离 + system 声明) - generate_image: SSRF 集成测试 + b64 OOM 估算纯函数测试(走查测试增强)
This commit is contained in:
+244
-21
@@ -101,21 +101,22 @@ pub fn all_tools() -> Vec<&'static ToolSpec> {
|
||||
spec("list_projects", "列出所有未删除项目", object_schema(json!({}), &[]), Low, list_projects),
|
||||
spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project),
|
||||
spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 active)")}), &["name", "description"]), Medium, create_project),
|
||||
spec("update_project", "更新项目(整体替换 description/status/path/stack)", object_schema(json!({"id": str_field("项目 ID"), "name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态")}), &["id", "name", "description"]), Medium, update_project),
|
||||
spec("update_project", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)")}), &["id"]), Medium, update_project),
|
||||
spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project),
|
||||
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
|
||||
// ─── 任务 ───
|
||||
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)")}), &[]), Low, list_tasks),
|
||||
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["project_id", "title", "description"]), Medium, create_task),
|
||||
spec("update_task", "更新任务(整体替换)", object_schema(json!({"id": str_field("任务 ID"), "project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "project_id", "title", "description"]), Medium, update_task),
|
||||
spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), Medium, update_task),
|
||||
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
|
||||
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
|
||||
// ─── 灵感 ───
|
||||
spec("list_ideas", "列出所有想法/灵感", object_schema(json!({}), &[]), Low, list_ideas),
|
||||
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea),
|
||||
spec("update_idea", "更新想法(整体替换)", object_schema(json!({"id": str_field("想法 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "title", "description"]), Medium, update_idea),
|
||||
spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)")}), &["id"]), Medium, update_idea),
|
||||
spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
|
||||
spec("evaluate_idea", "对想法做启发式评估(只读,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
|
||||
spec("evaluate_idea", "对想法做启发式评估(只读:只返分数不写库,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
|
||||
spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Medium, score_idea),
|
||||
// ─── 工作流(High) ───
|
||||
spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow),
|
||||
// ─── 回收站 ───
|
||||
@@ -148,7 +149,7 @@ fn spec(
|
||||
// 工具查找
|
||||
// ============================================================
|
||||
|
||||
/// 按 name 查找工具(线性扫描,工具数 19,O(n) 足够)。
|
||||
/// 按 name 查找工具(线性扫描,工具数 20,O(n) 足够)。
|
||||
pub fn find(name: &str) -> Option<&'static ToolSpec> {
|
||||
all_tools().into_iter().find(|t| t.tool.name == name)
|
||||
}
|
||||
@@ -268,18 +269,19 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let name = arg_str_or(&args, "name", "");
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let status = arg_str_or(&args, "status", "planning");
|
||||
medium_audit("update_project", &id);
|
||||
Box::pin(async move {
|
||||
let repo = ProjectRepo::new(&db);
|
||||
// 先读现有保留 path/stack/idea_id
|
||||
// 先读现有保留 path/stack/idea_id,以及未传字段的回退源(部分更新语义)
|
||||
let existing = match repo.get_by_id(&id).await {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return CallToolResult::error(format!("项目不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据
|
||||
let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone());
|
||||
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
|
||||
let status = arg_str(&args, "status").unwrap_or_else(|_| existing.status.as_str().to_owned());
|
||||
let now = now_millis();
|
||||
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
|
||||
let rec = ProjectRecord {
|
||||
@@ -424,15 +426,6 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let project_id = match arg_str(&args, "project_id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let title = match arg_str(&args, "title") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
medium_audit("update_task", &id);
|
||||
Box::pin(async move {
|
||||
let repo = TaskRepo::new(&db);
|
||||
@@ -441,6 +434,10 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Ok(None) => return CallToolResult::error(format!("任务不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 部分更新:project_id/title/description 缺省回退 existing,避免空默认清空数据
|
||||
let project_id = arg_str(&args, "project_id").unwrap_or_else(|_| existing.project_id.clone());
|
||||
let title = arg_str(&args, "title").unwrap_or_else(|_| existing.title.clone());
|
||||
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
|
||||
let now = now_millis();
|
||||
let rec = TaskRecord {
|
||||
id: id.clone(),
|
||||
@@ -566,8 +563,6 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let title = arg_str_or(&args, "title", "");
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
medium_audit("update_idea", &id);
|
||||
Box::pin(async move {
|
||||
let repo = IdeaRepo::new(&db);
|
||||
@@ -576,6 +571,9 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 部分更新:title/description 缺省回退 existing,避免空默认清空数据
|
||||
let title = arg_str(&args, "title").unwrap_or_else(|_| existing.title.clone());
|
||||
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
|
||||
let now = now_millis();
|
||||
let rec = IdeaRecord {
|
||||
id: id.clone(),
|
||||
@@ -610,6 +608,10 @@ fn delete_idea(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
)))
|
||||
}
|
||||
|
||||
/// 对想法做启发式评估(**只读,纯计算**):基于 description/title 计算
|
||||
/// feasibility/impact/urgency/overall,只返分数不写库(对齐 Low=只读契约)。
|
||||
///
|
||||
/// 需要把分数写回 DB 的,用 [`score_idea`](Medium 风险,写库)。
|
||||
fn evaluate_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
@@ -623,7 +625,33 @@ fn evaluate_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 启发式评分(本地确定性,不调 LLM)
|
||||
// 启发式评分(本地确定性纯函数,不调 LLM,不写库)
|
||||
let scores = heuristic_scores(&idea.title, &idea.description);
|
||||
// 原样回 idea(未改库),仅供客户端预览;写库请走 score_idea
|
||||
json_ok(json!({ "id": id, "idea": idea, "scores": scores }))
|
||||
})
|
||||
}
|
||||
|
||||
/// 评分并写库(Medium 风险):对想法做启发式评估,把 scores 写回 DB,
|
||||
/// 返回更新后的记录 + scores。read-only 模式会被 dispatch 拒绝。
|
||||
///
|
||||
/// 评分逻辑与 [`evaluate_idea`](Low 只读)共用 [`heuristic_scores`] 纯函数,
|
||||
/// 唯一差异是这里做 `update_full`(写副作用 → Medium)。
|
||||
fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let db = ctx.db.clone();
|
||||
let id = match arg_str(&args, "id") {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
medium_audit("score_idea", &id);
|
||||
Box::pin(async move {
|
||||
let repo = IdeaRepo::new(&db);
|
||||
let idea = match repo.get_by_id(&id).await {
|
||||
Ok(Some(i)) => i,
|
||||
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 与 evaluate_idea 共用的纯函数评分
|
||||
let scores = heuristic_scores(&idea.title, &idea.description);
|
||||
let now = now_millis();
|
||||
// 写回 scores 字段(整体更新)
|
||||
@@ -726,3 +754,198 @@ fn normalize_path(p: &str) -> String {
|
||||
.to_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测:evaluate_idea(只读,不写库)/ score_idea(写库)/ 风险契约
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::ContentBlock;
|
||||
use df_storage::crud::IdeaRepo;
|
||||
use df_storage::models::IdeaRecord;
|
||||
use df_types::types::{IdeaStatus, new_id};
|
||||
|
||||
/// 构造内存 DB + Ctx
|
||||
async fn test_ctx() -> Ctx {
|
||||
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
||||
Ctx::new(db)
|
||||
}
|
||||
|
||||
/// 从 CallToolResult 提取文本内容
|
||||
fn text_of(r: &CallToolResult) -> &str {
|
||||
match &r.content[0] {
|
||||
ContentBlock::Text { text } => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// 取 CallToolResult 的 JSON 文本并解析为 Value
|
||||
fn json_of(r: &CallToolResult) -> Value {
|
||||
serde_json::from_str(text_of(r)).unwrap()
|
||||
}
|
||||
|
||||
/// 插入一条想法,返回 (id, 原始 scores)
|
||||
async fn seed_idea(ctx: &Ctx, title: &str, desc: &str) -> String {
|
||||
let repo = IdeaRepo::new(&ctx.db);
|
||||
let now = now_millis();
|
||||
let rec = IdeaRecord {
|
||||
id: new_id(),
|
||||
title: title.to_owned(),
|
||||
description: desc.to_owned(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority: 0,
|
||||
score: None,
|
||||
tags: None,
|
||||
source: Some("test".to_owned()),
|
||||
promoted_to: None,
|
||||
ai_analysis: None,
|
||||
scores: None,
|
||||
related_ids: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
repo.insert(rec).await.unwrap()
|
||||
}
|
||||
|
||||
/// 读当前 DB 中的 idea.scores(原始字符串)
|
||||
async fn db_scores(ctx: &Ctx, id: &str) -> Option<String> {
|
||||
IdeaRepo::new(&ctx.db)
|
||||
.get_by_id(id)
|
||||
.await
|
||||
.unwrap()
|
||||
.and_then(|i| i.scores)
|
||||
}
|
||||
|
||||
// ── evaluate_idea:Low 只读契约 ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_idea_returns_scores_without_writing_db() {
|
||||
let ctx = test_ctx().await;
|
||||
let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块以解除阻塞").await;
|
||||
|
||||
let r = evaluate_idea(&ctx, json!({ "id": id })).await;
|
||||
assert!(r.is_error.is_none(), "evaluate_idea 不应返回错误");
|
||||
|
||||
let v = json_of(&r);
|
||||
assert_eq!(v["id"], id);
|
||||
// scores 维度齐
|
||||
assert!(v["scores"]["feasibility"].is_number());
|
||||
assert!(v["scores"]["impact"].is_number());
|
||||
assert!(v["scores"]["urgency"].is_number());
|
||||
assert!(v["scores"]["overall"].is_number());
|
||||
|
||||
// 契约核心:DB 中 scores 仍为 None(没写库)
|
||||
assert!(
|
||||
db_scores(&ctx, &id).await.is_none(),
|
||||
"evaluate_idea 违反只读契约:DB scores 被写"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_idea_missing_id_arg_errors() {
|
||||
let ctx = test_ctx().await;
|
||||
let r = evaluate_idea(&ctx, json!({})).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
assert!(text_of(&r).contains("缺少必填参数"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn evaluate_idea_unknown_id_errors() {
|
||||
let ctx = test_ctx().await;
|
||||
let r = evaluate_idea(&ctx, json!({ "id": "no-such-id" })).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
assert!(text_of(&r).contains("想法不存在"));
|
||||
}
|
||||
|
||||
// ── score_idea:Medium 写库契约 ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn score_idea_writes_scores_to_db() {
|
||||
let ctx = test_ctx().await;
|
||||
let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块以解除阻塞").await;
|
||||
// 前置:写前 DB scores 为空
|
||||
assert!(db_scores(&ctx, &id).await.is_none());
|
||||
|
||||
let r = score_idea(&ctx, json!({ "id": id })).await;
|
||||
assert!(r.is_error.is_none(), "score_idea 不应返回错误");
|
||||
|
||||
let v = json_of(&r);
|
||||
assert_eq!(v["id"], id);
|
||||
let scores_str = v["idea"]["scores"].as_str();
|
||||
assert!(scores_str.is_some(), "返回的 idea.scores 应非空(已写库)");
|
||||
let persisted = db_scores(&ctx, &id).await;
|
||||
assert!(persisted.is_some(), "DB scores 应已写入");
|
||||
// 返回值里的 scores 字符串 == DB 持久化的字符串(一致性)
|
||||
assert_eq!(scores_str.unwrap(), persisted.as_deref().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn score_idea_unknown_id_errors() {
|
||||
let ctx = test_ctx().await;
|
||||
let r = score_idea(&ctx, json!({ "id": "no-such-id" })).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
assert!(text_of(&r).contains("想法不存在"));
|
||||
}
|
||||
|
||||
// ── 风险契约(工具注册表)──────────────────────────────────────
|
||||
//
|
||||
// 锁定拆分的根本契约:evaluate_idea=Low(只读,read-only 放行),
|
||||
// score_idea=Medium(写库,read-only 拒)。改回合并即此测会红。
|
||||
|
||||
#[test]
|
||||
fn evaluate_idea_is_low_and_score_idea_is_medium() {
|
||||
let eval = find("evaluate_idea").expect("evaluate_idea 必须注册");
|
||||
let score = find("score_idea").expect("score_idea 必须注册");
|
||||
assert_eq!(
|
||||
eval.risk,
|
||||
RiskLevel::Low,
|
||||
"evaluate_idea 必须 Low(只读契约)"
|
||||
);
|
||||
assert_eq!(
|
||||
score.risk,
|
||||
RiskLevel::Medium,
|
||||
"score_idea 必须 Medium(写库 → read-only 拒)"
|
||||
);
|
||||
}
|
||||
|
||||
/// read-only 可见性:end-to-end 验证 dispatch 层对两个工具的过滤。
|
||||
/// (与 server.rs 测试呼应,锁定 read-only 放 evaluate / 拒 score 的契约)
|
||||
#[test]
|
||||
fn read_only_visibility_splits_evaluate_and_score() {
|
||||
// read-only:evaluate(Low)可见,score(Medium)不可见
|
||||
assert!(visible_for_test(true, "evaluate_idea"));
|
||||
assert!(!visible_for_test(true, "score_idea"));
|
||||
// 非 read-only:两者都可见
|
||||
assert!(visible_for_test(false, "evaluate_idea"));
|
||||
assert!(visible_for_test(false, "score_idea"));
|
||||
}
|
||||
|
||||
// 辅助:复用 server.rs 的 visible 谓词语义(本地重写,避免跨模块私有依赖)
|
||||
fn visible_for_test(read_only: bool, name: &str) -> bool {
|
||||
let spec = find(name).expect("工具存在");
|
||||
if read_only {
|
||||
spec.risk == RiskLevel::Low
|
||||
} else {
|
||||
spec.risk != RiskLevel::High
|
||||
}
|
||||
}
|
||||
|
||||
// ── heuristic_scores 纯函数:两工具共用,确定性 ──────────────────
|
||||
|
||||
#[test]
|
||||
fn heuristic_scores_is_deterministic_and_bounded() {
|
||||
let a = heuristic_scores("核心功能", "这是非常重要的关键模块,需要紧急处理");
|
||||
let b = heuristic_scores("核心功能", "这是非常重要的关键模块,需要紧急处理");
|
||||
assert_eq!(a, b, "相同输入应得相同分数(纯函数)");
|
||||
|
||||
let s = &a;
|
||||
for k in ["feasibility", "impact", "urgency", "overall"] {
|
||||
let v = s[k].as_f64().unwrap();
|
||||
assert!(
|
||||
(0.0..=9.0).contains(&v),
|
||||
"{k} 分数 {v} 越界 [0,9]"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user