新增: 无人值守P0任务边界全链
- 任务级授权边界判定与审批旁路单点插桩 - boundary_json 随会话落库(V45 可逆迁移) - 六路非正常终局统一安全停靠与托盘通知 - 每五轮进度事件与会话头部进度条 - 前端边界配置卡侧栏徽标及 UX 校验补齐
This commit is contained in:
@@ -31,7 +31,7 @@ mod diff;
|
||||
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
|
||||
// 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越
|
||||
pub(crate) mod path_auth;
|
||||
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PATH_AUTH_BYPASS_KEY};
|
||||
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PathBypass, PATH_AUTH_BYPASS_KEY};
|
||||
// 黑名单放行判据:tool_registry 的 validate_path 无 bypass 参数,经此跨模块读进程级标志
|
||||
pub(super) use path_auth::is_blacklist_bypassed;
|
||||
// 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩
|
||||
@@ -78,7 +78,7 @@ pub(crate) use finalize::audit_finalize;
|
||||
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
|
||||
// 第三批从本文件抽离,行为零变更。
|
||||
mod cache;
|
||||
pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, pending_placeholder_for, readonly_cache_args_key};
|
||||
pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, mark_readonly_cache_stale, pending_placeholder_for, readonly_cache_args_key};
|
||||
|
||||
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
|
||||
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
||||
@@ -166,8 +166,69 @@ async fn deny_sub_agent_tool(
|
||||
"rejected", risk_level, Some(msg), Some("sub_agent_denied"), current_message_id).await;
|
||||
}
|
||||
|
||||
/// 任务边界拒绝单点(无人值守,设计 §三):结构化失败 tool_result + emit Completed + 审计留痕。
|
||||
/// 不 insert pending_approvals、不 emit AiApprovalRequired/AiDirAuthRequired——边界会话
|
||||
/// 零新挂起(P1-1 治本面:挂起等输入是无人值守的主要敌人),LLM 从 envelope 的
|
||||
/// denial_layer + 行动提示自纠(换路径/拆低风险步骤/写入报告未尽事项),fail-closed
|
||||
/// 同构子代理拒绝协议。decided_by 与 denial_layer 同值留痕(boundary-dirs/boundary-risk,
|
||||
/// 设计 §3.2:事后全量追溯)。
|
||||
async fn deny_boundary_tool(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
draft: &ToolCallDraft,
|
||||
tools_arc: &Arc<AiToolRegistry>,
|
||||
audit_repo: &AiToolExecutionRepo,
|
||||
layer: &str,
|
||||
reason: &str,
|
||||
current_message_id: Option<&str>,
|
||||
app_handle: &AppHandle,
|
||||
) {
|
||||
// 各拒绝层的可改尝试方向(行动提示,让 LLM 下一步有路可走)
|
||||
let hint = match layer {
|
||||
"boundary-dirs" => "该路径不在本任务目录白名单内,改用白名单内路径完成等价操作,或在报告未尽事项中说明",
|
||||
_ => "该工具风险超出本任务风险上限,拆解为上限内的低风险步骤,或在报告未尽事项中说明由用户后续处理",
|
||||
};
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
let msg = serde_json::json!({
|
||||
"status": "rejected",
|
||||
"reason": "boundary_exceeded",
|
||||
"tool_name": draft.name,
|
||||
"risk_level": risk_level,
|
||||
"denial_layer": layer,
|
||||
"message": format!("任务边界外操作已拒绝(无人值守模式零打断):{}。{}", reason, hint)
|
||||
}).to_string();
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg));
|
||||
log_slow_lock(__lock_t, "process_tool_calls:deny_boundary");
|
||||
}
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
emit_ai_chat_event(app_handle, ev);
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"rejected", risk_level, Some(msg), Some(layer), current_message_id).await;
|
||||
}
|
||||
|
||||
/// P2-1 执行期白名单判定(纯函数可单测):SubAgent 策略下工具是否被白名单裁掉。
|
||||
/// spawn 入口的白名单钳制只裁工具定义面(LLM 可见 tools 列表),拦不住幻觉调用面外工具;
|
||||
/// 此判定让裁剪在执行期同样生效。allowed=None(主会话调用方)恒放行,行为与历史版本一致。
|
||||
fn sub_agent_tool_clamped(allowed: Option<&[String]>, tool_name: &str) -> bool {
|
||||
match allowed {
|
||||
None => false,
|
||||
Some(list) => !list.iter().any(|n| n == tool_name),
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
|
||||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||||
///
|
||||
/// `allowed`(P2-1):SubAgent 策略的执行期工具白名单(spawn 入口钳制产物)。
|
||||
/// Low 风险工具不在名单同样就地拒绝(layer=whitelist-clamp);主会话调用方传 None,语义不变。
|
||||
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
|
||||
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
|
||||
///
|
||||
@@ -481,6 +542,33 @@ async fn cancel_expired_approval(
|
||||
.await;
|
||||
// 5. 落库会话状态变更(占位消息替换后需持久化,防重启后丢失)
|
||||
super::conversation::save_conversation(&state.ai_session, &state.db, conv_id, None, None, true).await;
|
||||
// 6. P1-1 修:清 generating 幽灵态。挂起时主 loop 已 return 且 guard.disarm()
|
||||
// (保持 Generating 等审批恢复),超时取消后无人复位 → conv_state 恒 Generating,
|
||||
// 会话永久卡生成态(消息发不进,can_accept_request=false)。迁回 Idle + emit 状态事件,
|
||||
// 写法对齐 ai_chat_stop 审批分支(transition 非法仅 warn 不阻断停靠)。
|
||||
if let Err(e) = state
|
||||
.conv_states
|
||||
.transition(conv_id, crate::commands::ai::agentic::conv_state::ConvState::Idle)
|
||||
{
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
"[ai] 审批超时取消 conv_states→Idle 迁移非法(不阻断停靠事件)"
|
||||
);
|
||||
}
|
||||
// 7. 安全停靠(设计 §四):审批超时取消属非正常终局,补发 AiTaskParked + NeedAction
|
||||
// 通知。原状只取消不复跑不留痕——用户(尤其托盘场景)对任务已死毫不知情。
|
||||
// 不自动续跑(设计拍板点 5:恢复须可人工确认);恢复入口=既有重发/ai_chat_force_send。
|
||||
let (park_reason, park_kind) = approval_timeout_park_parts(timeout_minutes);
|
||||
super::emit_task_parked(app, conv_id, &park_reason, park_kind);
|
||||
}
|
||||
|
||||
/// 审批超时停靠文案构造(纯函数,单测锚点;stop_kind 复用 AiTaskParked 词表)。
|
||||
pub(crate) fn approval_timeout_park_parts(timeout_minutes: u64) -> (String, &'static str) {
|
||||
(
|
||||
format!("工具审批超时({}分钟)自动取消,任务已安全停靠:被拒操作已回执给 AI,重发消息或补充指令即可继续", timeout_minutes),
|
||||
"approval_timeout",
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn process_tool_calls(
|
||||
@@ -491,6 +579,7 @@ pub(crate) async fn process_tool_calls(
|
||||
app_handle: &AppHandle,
|
||||
conv_id: &str,
|
||||
policy: ToolExecPolicy,
|
||||
allowed: Option<&[String]>,
|
||||
) -> usize {
|
||||
// 策略快照(SubAgent:须人工介入的路径全部就地拒绝,见 ToolExecPolicy 文档)
|
||||
let is_sub_agent = matches!(policy, ToolExecPolicy::SubAgent);
|
||||
@@ -598,6 +687,18 @@ pub(crate) async fn process_tool_calls(
|
||||
.await
|
||||
.unwrap_or_else(|| "low".to_string());
|
||||
|
||||
// ── 无人值守任务边界快照(设计 §三判定链输入)──
|
||||
// per_conv.boundary 即会话级单真相源,None=未声明:下方两处插桩分支全部跳过,
|
||||
// auto_exec_mode/trust/审批/路径授权全链路现状零变更(渐进增强零倒退)。
|
||||
// 边界优先于全局三档:有边界会话上限内零打断,超上限结构化拒绝(不挂起不弹卡)。
|
||||
let boundary: Option<crate::commands::ai::boundary::TaskBoundary> = {
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let session = session_arc.lock().await;
|
||||
let b = session.conv_read(conv_id).and_then(|c| c.boundary.clone());
|
||||
log_slow_lock(__lock_t, "process_tool_calls:boundary_snapshot");
|
||||
b
|
||||
};
|
||||
|
||||
// ── 文件工具路径授权预校验 ──
|
||||
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
|
||||
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
|
||||
@@ -623,6 +724,14 @@ pub(crate) async fn process_tool_calls(
|
||||
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
|
||||
// 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
|
||||
path_auth::set_blacklist_bypass(path_auth_bypass);
|
||||
// P3-4 bypass 按策略分流:主会话=All(体验优先,黑名单一并放行,产品拍板);
|
||||
// SubAgent=KeepBlacklist——bypass 只豁免白名单外挂起,黑名单保持硬拒。子代理无审批
|
||||
// 通道也无人监督,黑名单是安全线,不得被体验开关解除。关=Off 两策略行为一致。
|
||||
let path_bypass = if path_auth_bypass {
|
||||
if is_sub_agent { PathBypass::KeepBlacklist } else { PathBypass::All }
|
||||
} else {
|
||||
PathBypass::Off
|
||||
};
|
||||
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
|
||||
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
|
||||
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||
@@ -630,11 +739,13 @@ pub(crate) async fn process_tool_calls(
|
||||
// 第三元=denial_layer:needs-auth(待授权)与 path-blacklist(系统黑名单)分开标注——
|
||||
// 黑名单主会话也放不了行,混标会误导子 LLM 走授权通道。
|
||||
let mut sub_agent_path_denied: Vec<(ToolCallDraft, String, &'static str)> = Vec::new();
|
||||
// 任务边界会话:白名单外待授权目录的拒绝收集(结构化拒绝,不挂起,见下方处理)
|
||||
let mut boundary_dir_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
|
||||
for (idx, draft, args) in drafts {
|
||||
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
|
||||
// handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。
|
||||
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_auth_bypass) {
|
||||
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_bypass) {
|
||||
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
|
||||
FileToolAuthOutcome::NeedsAuth(req) => {
|
||||
// SubAgent 策略:子代理无授权弹窗通道,挂起即永久停摆 → 就地拒绝。
|
||||
@@ -642,6 +753,21 @@ pub(crate) async fn process_tool_calls(
|
||||
sub_agent_path_denied.push((draft, "路径未授权且子代理无授权弹窗通道".to_string(), "needs-auth"));
|
||||
continue;
|
||||
}
|
||||
// 任务边界优先(无人值守,设计 §3.1②):req.dirs 是三档白名单外的待授权
|
||||
// 父目录集合,边界白名单全覆盖 → 直接放行(免弹 AiDirAuthRequired);
|
||||
// 未全覆盖 → 结构化拒绝(boundary-dirs),不挂起——边界会话零新挂起。
|
||||
if let Some(b) = &boundary {
|
||||
if crate::commands::ai::boundary::covers_dirs(b, &req.dirs) {
|
||||
authorized_drafts.push((idx, draft, args));
|
||||
} else {
|
||||
let uncovered: Vec<String> = req.dirs.iter()
|
||||
.filter(|d| !crate::commands::ai::boundary::dir_covered(&b.dir_allowlist, d))
|
||||
.map(|d| d.to_string_lossy().to_string())
|
||||
.collect();
|
||||
boundary_dir_denied.push((draft, format!("待授权目录 {} 不在任务白名单内", uncovered.join(", "))));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
|
||||
// 黑名单 Denied 不受影响——系统敏感目录是绝对安全线,全接管也不放行。
|
||||
// 放行的 draft 走 authorized_drafts → RiskLevel 分类 → all 放行 → 审计留痕闭环。
|
||||
@@ -702,6 +828,15 @@ pub(crate) async fn process_tool_calls(
|
||||
).await;
|
||||
}
|
||||
|
||||
// 任务边界会话:白名单外路径结构化拒绝(boundary-dirs),零挂起零弹卡。
|
||||
// LLM 拿 envelope 换路径或写入报告未尽事项;审计 decided_by=boundary-dirs 留痕。
|
||||
for (draft, reason) in boundary_dir_denied {
|
||||
deny_boundary_tool(
|
||||
session_arc, conv_id, &draft, tools_arc, &audit_repo,
|
||||
"boundary-dirs", &reason, current_message_id, app_handle,
|
||||
).await;
|
||||
}
|
||||
|
||||
// Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
|
||||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
||||
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
||||
@@ -811,6 +946,12 @@ pub(crate) async fn process_tool_calls(
|
||||
// trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
|
||||
// (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
|
||||
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
|
||||
// boundary_auto 收集(无人值守,设计 §3.1③):边界会话上限内非破坏性工具,归入独立
|
||||
// 并行执行向量(join_all,与 trust_hits 同款执行+审计链)。不并入 low_risk 向量的原因:
|
||||
// low_risk 回填的 decided_by 按风险等级映射(auto/auto_takeover_*),边界放行的来源留痕
|
||||
// 须为 boundary_auto(审计可追溯授权来自任务边界而非全局档),且不动共享路径签名
|
||||
// (无边界会话零倒退优先)。
|
||||
let mut boundary_auto: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new();
|
||||
for (_, draft, args) in drafts {
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
// SubAgent 策略:Medium/High 就地拒绝(无审批通道,挂起即子循环永久停摆)。
|
||||
@@ -825,6 +966,39 @@ pub(crate) async fn process_tool_calls(
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
// P2-1 执行期白名单:Low 风险工具不在 spawn 白名单同样就地拒绝(定义面裁剪
|
||||
// 拦不住幻觉调用)。layer 恒 whitelist-clamp——是名单问题非风险问题,行动提示
|
||||
// 指向改用名单内工具。主会话 allowed=None 判定恒 false,零行为变更。
|
||||
if is_sub_agent && sub_agent_tool_clamped(allowed, &draft.name) {
|
||||
deny_sub_agent_tool(
|
||||
session_arc, conv_id, &draft, tools_arc, &audit_repo,
|
||||
"whitelist-clamp", "工具不在 spawn 白名单内", current_message_id, app_handle,
|
||||
).await;
|
||||
continue;
|
||||
}
|
||||
// 任务边界判定(无人值守,设计 §3.1③④;SubAgent 策略不受影响维持 fail-closed:
|
||||
// 子代理 per_conv 新建恒无边界,此处再 gate 一道,防误设边界放宽子代理——拍板点 6):
|
||||
// 上限内 ∧ 非破坏性命令 → boundary_auto 并行执行向量(零审批零挂起);
|
||||
// 超上限(含 High、破坏性命令)→ 结构化拒绝(boundary-risk),不 insert pending。
|
||||
// 黑名单恒拒不在此层——Denied 分支在 path_auth 预校验先行硬拒,边界永不参与。
|
||||
if !is_sub_agent {
|
||||
if let Some(b) = &boundary {
|
||||
if crate::commands::ai::boundary::risk_allowed(b, &draft.name, &args, risk_level) {
|
||||
boundary_auto.push((draft, args, risk_level));
|
||||
} else {
|
||||
let reason = if risk_level == RiskLevel::High {
|
||||
"工具风险 High 超出任务风险上限".to_string()
|
||||
} else {
|
||||
"工具风险超出任务风险上限".to_string()
|
||||
};
|
||||
deny_boundary_tool(
|
||||
session_arc, conv_id, &draft, tools_arc, &audit_repo,
|
||||
"boundary-risk", &reason, current_message_id, app_handle,
|
||||
).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
|
||||
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
|
||||
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
|
||||
@@ -893,6 +1067,7 @@ pub(crate) async fn process_tool_calls(
|
||||
}
|
||||
})).await;
|
||||
|
||||
|
||||
// 串行回填 tool_result + 审计(短 lock push + 锁外 audit)
|
||||
for (draft, risk_level, outcome) in results {
|
||||
let (status, content) = match outcome {
|
||||
@@ -914,6 +1089,12 @@ pub(crate) async fn process_tool_calls(
|
||||
content.clone()
|
||||
};
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content));
|
||||
// 写后失效:成功完成的非只读工具改变了世界状态,推进缓存失效下界
|
||||
// (floor 之前的历史只读结果不可信,find_cached_readonly_result 过滤)。仅
|
||||
// completed 才失效——failed 未改世界(命令失败即无副作用面),不误伤。
|
||||
if status == "completed" {
|
||||
mark_readonly_cache_stale(&mut session.conv(conv_id), &draft.name);
|
||||
}
|
||||
}
|
||||
// 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id).await;
|
||||
@@ -1067,6 +1248,12 @@ pub(crate) async fn process_tool_calls(
|
||||
content.clone()
|
||||
};
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content));
|
||||
// 写后失效:成功完成的非只读工具改变了世界状态,推进缓存失效下界
|
||||
// (floor 之前的历史只读结果不可信,find_cached_readonly_result 过滤)。仅
|
||||
// completed 才失效——failed 未改世界(命令失败即无副作用面),不误伤。
|
||||
if status == "completed" {
|
||||
mark_readonly_cache_stale(&mut session.conv(conv_id), &draft.name);
|
||||
}
|
||||
}
|
||||
// F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。
|
||||
// decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker:
|
||||
@@ -1135,32 +1322,98 @@ pub(crate) async fn process_tool_calls(
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &warned_content));
|
||||
}
|
||||
dupe_audit_records.push(build_audit_record(
|
||||
conv_id, &draft.id, &draft.name, &draft.args, "completed", risk_level,
|
||||
Some(warned_content), Some("cache_hit"), current_message_id,
|
||||
conv_id, &draft.id, &draft.name, &draft.args,
|
||||
"completed", risk_level, Some(warned_content), Some("cache_hit"),
|
||||
current_message_id,
|
||||
));
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
tool = %draft.name,
|
||||
tc_id = %draft.id,
|
||||
hit_count = new_count,
|
||||
"[ai] 只读工具轮内去重命中(第 {} 次同参,本轮首个已执行),跳过重复真执行",
|
||||
new_count
|
||||
);
|
||||
}
|
||||
if !dupe_audit_records.is_empty() {
|
||||
let dup_len = dupe_audit_records.len();
|
||||
let batch_len = dupe_audit_records.len();
|
||||
if let Err(e) = audit_repo.insert_batch(dupe_audit_records).await {
|
||||
tracing::error!(
|
||||
conv_id = %conv_id,
|
||||
batch = dup_len,
|
||||
"[ai] insert_batch 轮内去重审计批量写失败(已回滚,共 {} 条): {}",
|
||||
dup_len,
|
||||
batch = batch_len,
|
||||
"[ai] insert_batch(轮内重复)批量写审计记录失败(已回滚,共 {} 条): {}",
|
||||
batch_len,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// boundary_auto 并行执行(无人值守,设计 §3.1③):与 low_risk 同构——execute + emit 在
|
||||
// 闭包内(锁外),push/audit 串行回填;区别仅审计 decided_by=boundary_auto
|
||||
// (授权来自任务边界,事后全量可追溯),emit_data_changed 同步联动(数据变更类)。
|
||||
// execute/emit 全在闭包内不持锁,push tool_result / audit 在 join_all 后串行回填。
|
||||
if !boundary_auto.is_empty() {
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
count = boundary_auto.len(),
|
||||
"[ai] 任务边界放行 {} 个工具进入并行执行(decided_by=boundary_auto)",
|
||||
boundary_auto.len()
|
||||
);
|
||||
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> =
|
||||
futures::future::join_all(boundary_auto.into_iter().map(|(draft, args, risk_level)| {
|
||||
let tools = tools_arc.clone();
|
||||
let app_clone = app_handle.clone();
|
||||
let conv_clone = conv_id.to_string();
|
||||
async move {
|
||||
match execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone, &draft.id).await {
|
||||
Ok(val) => {
|
||||
// L3 emit 双写:边界放行工具执行成功 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: val.clone(),
|
||||
conversation_id: Some(conv_clone),
|
||||
};
|
||||
emit_ai_chat_event(&app_clone, ev);
|
||||
emit_data_changed(&app_clone, &draft.name);
|
||||
(draft, risk_level, Ok(val.to_string()))
|
||||
}
|
||||
Err(e) => {
|
||||
// AR-6 同款:失败包进 tool_result 让 LLM 自纠,不 emit AiError 不断轮。
|
||||
let err_msg = format!("工具 {} 执行失败: {}", draft.name, e);
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(err_msg.clone()),
|
||||
conversation_id: Some(conv_clone),
|
||||
};
|
||||
emit_ai_chat_event(&app_clone, ev);
|
||||
(draft, risk_level, Err(err_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
})).await;
|
||||
|
||||
for (draft, risk_level, outcome) in results {
|
||||
let (status, content) = match outcome {
|
||||
Ok(c) => ("completed", c),
|
||||
Err(c) => ("failed", c),
|
||||
};
|
||||
// 短 lock 段:namespace T2 + push tool_result(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
let msg_content = if status == "failed" {
|
||||
serde_json::json!({"status":"failed","error":&content}).to_string()
|
||||
} else if df_ai::namespace_store::should_use_namespace(&content, &draft.name) {
|
||||
session.namespace_store.store(&draft.name, &content)
|
||||
} else {
|
||||
content.clone()
|
||||
};
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content));
|
||||
// 写后失效:边界放行的非只读工具成功同 Low 段推进 floor(口径一致)。
|
||||
if status == "completed" {
|
||||
mark_readonly_cache_stale(&mut session.conv(conv_id), &draft.name);
|
||||
}
|
||||
}
|
||||
// 锁外审计:risk 真实等级透传(F-#97 防失真),decided_by=boundary_auto
|
||||
// 标注授权来自任务边界(无人值守事后可全量追溯)。
|
||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||
status, risk_level, Some(content), Some("boundary_auto"), current_message_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
||||
// 短 lock 段:读 pending_approvals 统计(纯读,无 await)
|
||||
let (path_count, risk_count, pending_approvals_len) = {
|
||||
@@ -1195,7 +1448,7 @@ mod tests {
|
||||
let allowed = crate::state::AllowedDirs::default();
|
||||
let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" });
|
||||
|
||||
let outcome = check_file_tool_auth("grep", &args, &allowed, false);
|
||||
let outcome = check_file_tool_auth("grep", &args, &allowed, PathBypass::Off);
|
||||
match outcome {
|
||||
FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ }
|
||||
FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"),
|
||||
@@ -1203,7 +1456,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异
|
||||
let search_outcome = check_file_tool_auth("search_files", &args, &allowed, false);
|
||||
let search_outcome = check_file_tool_auth("search_files", &args, &allowed, PathBypass::Off);
|
||||
match search_outcome {
|
||||
FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ }
|
||||
FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"),
|
||||
@@ -1219,7 +1472,7 @@ mod tests {
|
||||
let allowed = crate::state::AllowedDirs::default_with_root();
|
||||
// .ssh 命中系统黑名单(is_in_system_blacklist)
|
||||
let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" });
|
||||
let outcome = check_file_tool_auth("grep", &args, &allowed, false);
|
||||
let outcome = check_file_tool_auth("grep", &args, &allowed, PathBypass::Off);
|
||||
match outcome {
|
||||
FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ }
|
||||
FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"),
|
||||
@@ -1234,4 +1487,39 @@ mod tests {
|
||||
let paths = extract_file_tool_paths("grep", &args);
|
||||
assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)");
|
||||
}
|
||||
|
||||
/// P2-1 执行期白名单判定真值表:SubAgent 名单外工具(含已注册 Low)被裁,名单内放行,
|
||||
/// 主会话 None 恒放行(历史行为不变)。与 spawn 入口 clamp_tool_whitelist 组成两道闸;
|
||||
/// 判定命中后的拒绝落地走既有 deny_sub_agent_tool 单点(layer=whitelist-clamp,
|
||||
/// envelope 结构与被拒收集由 sub_agent.rs 测试锁定),此处锁判定本身。
|
||||
#[test]
|
||||
fn sub_agent_tool_clamped_truth_table() {
|
||||
let allowed = vec!["read_file".to_string(), "grep".to_string()];
|
||||
assert!(!sub_agent_tool_clamped(Some(&allowed), "read_file"), "名单内放行");
|
||||
assert!(
|
||||
sub_agent_tool_clamped(Some(&allowed), "list_tasks"),
|
||||
"名单外已注册 Low 工具同样被裁(定义面裁剪拦不住的幻觉调用残面)"
|
||||
);
|
||||
assert!(
|
||||
sub_agent_tool_clamped(Some(&allowed), "spawn_agent"),
|
||||
"spawn_agent 恒被入口钳制剔除,执行期也不得放行(禁递归)"
|
||||
);
|
||||
assert!(!sub_agent_tool_clamped(None, "run_command"), "主会话 None 恒放行");
|
||||
assert!(
|
||||
sub_agent_tool_clamped(Some(&[]), "read_file"),
|
||||
"空白名单全拒(白名单为空的子代理无可执行工具)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 审批超时停靠文案(stop_kind=approval_timeout):锁定 P1-1 修复的停靠出口字段,
|
||||
/// cancel_expired_approval 到点路径消费(发 AiTaskParked + NeedAction 通知)。
|
||||
#[test]
|
||||
fn approval_timeout_park_parts_contract() {
|
||||
let (reason, kind) = approval_timeout_park_parts(15);
|
||||
assert_eq!(kind, "approval_timeout", "stop_kind 词表(前端契约)");
|
||||
assert!(reason.contains("15"), "文案携带超时分钟数");
|
||||
assert!(reason.contains("停靠"), "文案含停靠语义(待办清单=stop_reason)");
|
||||
let (reason_0, _) = approval_timeout_park_parts(0);
|
||||
assert!(reason_0.contains("0"), "任意分钟数可格式化(0=禁用超时不会到点,防御)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,22 +59,38 @@ pub(crate) enum FileToolAuthOutcome {
|
||||
Denied(String),
|
||||
}
|
||||
|
||||
/// 「信任所有目录」总开关的放行面(P3-4 按策略分流)。
|
||||
///
|
||||
/// - `Off`:开关关闭(默认),白名单外挂起授权 + 黑名单硬拒,行为与历史版本一致;
|
||||
/// - `All`:主会话全放行(体验优先,黑名单一并放行,产品拍板);
|
||||
/// - `KeepBlacklist`:子代理策略——bypass 只豁免白名单外挂起(含 search_files 盲拒),
|
||||
/// 黑名单保持硬拒。子代理无审批通道也无人监督,黑名单是安全线,不可被体验开关解除。
|
||||
/// Copy:每条工具调用循环内按值传入 check_file_tool_auth,单元枚举按位复制免手动 clone。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum PathBypass {
|
||||
Off,
|
||||
All,
|
||||
KeepBlacklist,
|
||||
}
|
||||
|
||||
/// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
|
||||
///
|
||||
/// - 路径任一命中黑名单且 bypass 关:`Denied(reason)` 硬拒(工具返 Err tool_result,不挂起)
|
||||
/// - 路径任一命中黑名单且 bypass 非 `All`:`Denied(reason)` 硬拒(工具返 Err tool_result,不挂起)
|
||||
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
|
||||
/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗
|
||||
/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程
|
||||
/// - `bypass=true`(「信任所有目录」总开关):未授权目录不再产生 NeedsAuth,黑名单 Denied
|
||||
/// 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒
|
||||
/// 随 NeedsAuth 消失一并解除)。bypass 由调用方 process_tool_calls 读 KV 后传入;
|
||||
/// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(与入参同源等价,
|
||||
/// audit 层每轮刷新保证两处拦截点一致)。本函数同步签名,不自取异步 settings。
|
||||
/// - `bypass=All`(「信任所有目录」总开关,主会话):未授权目录不再产生 NeedsAuth,黑名单
|
||||
/// Denied 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒
|
||||
/// 随 NeedsAuth 消失一并解除)。`bypass=KeepBlacklist`(子代理):只豁免 NeedsAuth,
|
||||
/// 黑名单硬拒保持。bypass 由调用方 process_tool_calls 读 KV 后按策略分流传入;
|
||||
/// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(主会话语义,与 All 同源等价,
|
||||
/// audit 层每轮刷新保证两处拦截点一致;子代理文件工具在预校验层已硬拒,handler 级标志不生效)。
|
||||
/// 本函数同步签名,不自取异步 settings。
|
||||
pub(crate) fn check_file_tool_auth(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
allowed: &crate::state::AllowedDirs,
|
||||
bypass: bool,
|
||||
bypass: PathBypass,
|
||||
) -> FileToolAuthOutcome {
|
||||
use crate::state::{check_path_authorization, PathAuthDecision};
|
||||
let paths = extract_file_tool_paths(tool_name, args);
|
||||
@@ -88,16 +104,17 @@ pub(crate) fn check_file_tool_auth(
|
||||
for p in &paths {
|
||||
match check_path_authorization(p, allowed) {
|
||||
PathAuthDecision::Denied { reason } => {
|
||||
// 总开关开启时黑名单一并放行(产品拍板:体验优先,用户知情选择);关闭时硬拒不变。
|
||||
// 总开关=All 时黑名单一并放行(产品拍板:体验优先,用户知情选择,仅主会话);
|
||||
// 关闭与 KeepBlacklist(子代理)时硬拒不变——黑名单是安全线。
|
||||
// 此处用入参 bypass;validate_path 无参才走全局标志,两者同源等价(audit 每轮刷同一 KV)。
|
||||
if bypass {
|
||||
if matches!(bypass, PathBypass::All) {
|
||||
continue;
|
||||
}
|
||||
return FileToolAuthOutcome::Denied(reason);
|
||||
}
|
||||
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||
// 总开关开启:未授权目录直接放行(黑名单的放行在上一分支处理)
|
||||
if bypass {
|
||||
// 总开关开启(All 或 KeepBlacklist):未授权目录直接放行(黑名单的放行仅在 All 分支)
|
||||
if !matches!(bypass, PathBypass::Off) {
|
||||
continue;
|
||||
}
|
||||
if !pending_dirs.contains(&dir) {
|
||||
@@ -136,17 +153,43 @@ mod tests {
|
||||
let allowed = crate::state::AllowedDirs::default();
|
||||
let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" });
|
||||
// 关(基线,行为不变):NeedsAuth
|
||||
match check_file_tool_auth("read_file", &args, &allowed, false) {
|
||||
match check_file_tool_auth("read_file", &args, &allowed, PathBypass::Off) {
|
||||
FileToolAuthOutcome::NeedsAuth(_) => {}
|
||||
_ => panic!("bypass 关时未授权路径应仍返 NeedsAuth"),
|
||||
}
|
||||
// 开:直接 Authorized,不挂起
|
||||
match check_file_tool_auth("read_file", &args, &allowed, true) {
|
||||
match check_file_tool_auth("read_file", &args, &allowed, PathBypass::All) {
|
||||
FileToolAuthOutcome::Authorized => {}
|
||||
_ => panic!("bypass 开时未授权路径应直接 Authorized"),
|
||||
}
|
||||
}
|
||||
|
||||
/// P3-4 SubAgent 分流:KeepBlacklist 只豁免白名单外挂起(不再弹授权),黑名单保持硬拒
|
||||
/// ——子代理无审批通道也无人监督,黑名单安全线不得被体验开关解除。
|
||||
#[test]
|
||||
fn test_bypass_keep_blacklist_passes_needs_auth_but_denies_blacklist() {
|
||||
let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let allowed = crate::state::AllowedDirs::default_with_root();
|
||||
// 白名单外普通路径:KeepBlacklist 直接放行(不挂起授权)
|
||||
let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" });
|
||||
assert!(
|
||||
matches!(
|
||||
check_file_tool_auth("read_file", &args, &allowed, PathBypass::KeepBlacklist),
|
||||
FileToolAuthOutcome::Authorized
|
||||
),
|
||||
"KeepBlacklist 应豁免白名单外挂起"
|
||||
);
|
||||
// 黑名单路径:仍硬拒(与 All 的语义分界)
|
||||
let secret = serde_json::json!({ "path": "/home/user/.ssh/config" });
|
||||
assert!(
|
||||
matches!(
|
||||
check_file_tool_auth("read_file", &secret, &allowed, PathBypass::KeepBlacklist),
|
||||
FileToolAuthOutcome::Denied(_)
|
||||
),
|
||||
"KeepBlacklist 下黑名单应保持硬拒"
|
||||
);
|
||||
}
|
||||
|
||||
/// bypass 开:黑名单也放行(语义反转:总开关=完全放行,含敏感目录)。
|
||||
/// 进程级标志须持串行闸并在断言前复位(先取结果再复位再断言,防 panic 泄漏标志污染并行测试)。
|
||||
#[test]
|
||||
@@ -157,7 +200,7 @@ mod tests {
|
||||
// .ssh 命中系统黑名单(is_in_system_blacklist)
|
||||
let args = serde_json::json!({ "path": "/home/user/.ssh/config" });
|
||||
let outcome_allowed = matches!(
|
||||
check_file_tool_auth("read_file", &args, &allowed, true),
|
||||
check_file_tool_auth("read_file", &args, &allowed, PathBypass::All),
|
||||
FileToolAuthOutcome::Authorized
|
||||
);
|
||||
set_blacklist_bypass(false);
|
||||
@@ -171,7 +214,7 @@ mod tests {
|
||||
let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let allowed = crate::state::AllowedDirs::default_with_root();
|
||||
let args = serde_json::json!({ "path": "/home/user/.ssh/config" });
|
||||
match check_file_tool_auth("read_file", &args, &allowed, false) {
|
||||
match check_file_tool_auth("read_file", &args, &allowed, PathBypass::Off) {
|
||||
FileToolAuthOutcome::Denied(_) => {}
|
||||
_ => panic!("bypass 关时黑名单路径应 Denied(行为不变)"),
|
||||
}
|
||||
|
||||
@@ -296,15 +296,23 @@ fn match_task_audit_row(tool_name: &str, result: Option<&str>, task_id: &str) ->
|
||||
/// 故走专用 SELECT(与 state.rs 清理孤儿 pending 消息同款 app 层直查先例);SQL 里的工具名单
|
||||
/// 是 TASK_CONV_TOOLS 的硬拷贝,单测覆盖三个名字防漂移。
|
||||
///
|
||||
/// P2-3 task_id 预筛下沉 SQL:`result LIKE '%"id":"<task_id>"%'` 只作预筛(缩小扫描窗口,
|
||||
/// 参数化绑定防注入),精确判等仍在 Rust 侧 match_task_audit_row(解析 JSON 取顶层 id 字段
|
||||
/// 判等,「禁裸 LIKE 判等」不破)。预筛只会多放进相关行(嵌套 id/巧合同形文本),由 Rust
|
||||
/// 侧剔除;不会漏放——audit result 均由 serde_json 紧凑序列化落库(无空格),顶层 id 字段
|
||||
/// 必然以 `"id":"<id>"` 形态出现。若无预筛,LIMIT 窗口取的是全局最近 1000 行,高流量下
|
||||
/// 目标任务的关联行会被无关行挤出窗外(关联静默消失);预筛后窗口=含该任务 id 形文本的
|
||||
/// 最近 1000 行,早期关联仍可命中(有单测锁定)。
|
||||
///
|
||||
/// 过滤:① 命中行按会话去重(行按 requested_at 倒序,首见=最近一次);② 子代理会话
|
||||
/// (origin='sub_agent',V44)剔除,v1 只列主会话;③ 会话已不在库的 id 一并剔除
|
||||
/// (switchConversation 对不存在 id 会走"新建兜底",跳过去是空会话,不如不列)。
|
||||
/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢;取最近 1000 行,
|
||||
/// 更早的关联对"跳回最近上下文"场景无意义。
|
||||
/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢。
|
||||
pub(crate) async fn find_task_conversations(
|
||||
db: &Database,
|
||||
task_id: &str,
|
||||
) -> Result<Vec<TaskConversationRef>, String> {
|
||||
let pattern = format!("%\"id\":\"{}\"%", task_id);
|
||||
let conn = db.conn();
|
||||
let rows: Vec<(String, String, Option<String>, String)> =
|
||||
tokio::task::spawn_blocking(move || -> Result<Vec<(String, String, Option<String>, String)>, String> {
|
||||
@@ -315,11 +323,12 @@ pub(crate) async fn find_task_conversations(
|
||||
"SELECT conversation_id, tool_name, result, requested_at FROM ai_tool_executions \
|
||||
WHERE tool_name IN ('create_task','update_task','advance_task') \
|
||||
AND conversation_id IS NOT NULL \
|
||||
AND result LIKE ?1 \
|
||||
ORDER BY CAST(requested_at AS INTEGER) DESC LIMIT 1000",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mapped = stmt
|
||||
.query_map([], |row| {
|
||||
.query_map([&pattern], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
@@ -590,6 +599,7 @@ mod tests {
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
origin: origin.map(|s| s.to_string()),
|
||||
boundary_json: None,
|
||||
})
|
||||
.await
|
||||
.expect("测试会话插入应成功");
|
||||
@@ -637,6 +647,36 @@ mod tests {
|
||||
assert_eq!(out[0].requested_at, (t - 10).to_string());
|
||||
}
|
||||
|
||||
/// P2-3 窗口外早期关联仍可命中:目标任务行之后灌 1000+ 条更新的无关任务工具行,
|
||||
/// 旧的「全局最近 1000 条」窗口会把早期关联挤出(静默消失);task_id 预筛下沉后,
|
||||
/// 窗口按预筛结果截取,早期关联仍须命中(精确判等仍在 Rust 侧,误命中不发生)。
|
||||
#[tokio::test]
|
||||
async fn find_task_conversations_early_hit_survives_window_flood() {
|
||||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||
let repo = AiToolExecutionRepo::new(&db);
|
||||
let t_early = 1_000_000_000_000i64;
|
||||
|
||||
insert_conversation(&db, "conv-early", Some("早期会话"), None).await;
|
||||
insert_conv_audit(&repo, "conv-early", "create_task",
|
||||
Some(r#"{"id":"t-early","title":"E"}"#.into()), t_early).await;
|
||||
// 灌 1100 条更新的无关任务工具行(其他任务 id),把全表推过 1000 行窗口
|
||||
let t_flood = t_early + 1_000_000;
|
||||
for i in 0..1100i64 {
|
||||
insert_conv_audit(&repo, "conv-noise", "update_task",
|
||||
Some(format!(r#"{{"id":"noise-{}"}}"#, i)), t_flood + i).await;
|
||||
}
|
||||
|
||||
let out = find_task_conversations(&db, "t-early").await.unwrap();
|
||||
assert_eq!(out.len(), 1, "早期关联不得被 1000 行窗口挤出");
|
||||
assert_eq!(out[0].conversation_id, "conv-early");
|
||||
assert_eq!(out[0].title.as_deref(), Some("早期会话"));
|
||||
// 预筛不放无关行进来:noise 任务查不到任何关联
|
||||
assert!(
|
||||
find_task_conversations(&db, "noise-1").await.unwrap().is_empty(),
|
||||
"noise 会话不在库,即使 id 前缀巧合也不得列出"
|
||||
);
|
||||
}
|
||||
|
||||
/// S4 纯函数判定表:名单外工具 / 非 JSON / 缺 id / id 不等 均不命中,精确判等才命中。
|
||||
#[test]
|
||||
fn match_task_audit_row_truth_table() {
|
||||
|
||||
Reference in New Issue
Block a user