修复: 全库走查 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:
lxy
2026-08-02 03:11:44 +08:00
parent dffc4e4851
commit fc249adf17
7 changed files with 1000 additions and 82 deletions
+244 -21
View File
@@ -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]"
);
}
}
}