fix(presenter): M103 r1 —— 脱敏闸门补守卫,P2 五条带走
P1(守卫缺失,产品代码本来是对的): - 新增 agent/tests_red_line.rs:MessageAppended(绕过 agent_submit)、 ToolCallFinished(name+summary)、非 JWT 形状的 details(键名 / 嵌套 / 数组 / 值随 path 敏感)各一条;变异 A/H/C2 复跑确认变红 - tests.rs 拆出红线组(547 → 334 行,单文件 ≤400) P2: - scrub_details 补「值随 path 敏感」规则(对齐 agent::redact::redact_args) - scrub_text 补短 Bearer 掩码(对齐 agent::redact::mask_bearer) - pending_confirm_forces_busy_without_yolo 先 finish_stream 再断言(不再空过), 变异 F 现在两条测试同时变红 - MessageDelta 越界下标 debug_assert;ToolCallFinished 没有 pending 时忽略, 不再改写已收敛的历史条目 - Presenter::state_mut() 收窄成 #[cfg(test)],换成 navigate/back/toggle_ai/ prefs_mut/agent_set_yolo/agent_set_draft 等窄入口 - agent_submit 成功后立刻 begin_turn() 置忙,事件投影完 settle_streaming() 重算, 异步后端空窗不会再让用户点两次发送 - 补测:begin_turn 置忙、静默后端不留假忙、工具收尾不覆盖历史
This commit is contained in:
1 parent
a8d4cce3e2
commit
06461df91b
5 files changed
+435
-96
No files matched your search
@@ -37,16 +37,21 @@
|
|||||||
//! - 流式时序:端口是同步返回事件分片的(便于单测),真实实现是异步推流。
|
//! - 流式时序:端口是同步返回事件分片的(便于单测),真实实现是异步推流。
|
||||||
|
|
||||||
use crate::dto::{AppEvent, Sensitivity};
|
use crate::dto::{AppEvent, Sensitivity};
|
||||||
|
use crate::prefs::UiPrefs;
|
||||||
|
use crate::router::Route;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use rail::scrub_text;
|
|
||||||
|
|
||||||
pub mod mock;
|
pub mod mock;
|
||||||
pub mod rail;
|
pub mod rail;
|
||||||
|
|
||||||
|
use rail::scrub_text;
|
||||||
pub use rail::AgentRail;
|
pub use rail::AgentRail;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "agent/tests_red_line.rs"]
|
||||||
|
mod tests_red_line;
|
||||||
|
|
||||||
/// 裁决记录上限(只留最近若干条;权威审计不在这里)。
|
/// 裁决记录上限(只留最近若干条;权威审计不在这里)。
|
||||||
pub const MAX_AUDIT_ENTRIES: usize = 256;
|
pub const MAX_AUDIT_ENTRIES: usize = 256;
|
||||||
@@ -147,11 +152,49 @@ impl Presenter {
|
|||||||
&self.state
|
&self.state
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 可写状态(导航、偏好、其它页面事件;agent 相关动作请走本类的方法)。
|
/// 可写状态——**只在测试构建里存在**。
|
||||||
|
///
|
||||||
|
/// 非测试代码没有这个后门:`AppState::ai` 的字段全是 pub,返回 `&mut AppState` 就等于
|
||||||
|
/// 让调用方绕开 [`AgentRail::ingest`] 的脱敏与 `busy` 不变量。需要写状态时走下面这些
|
||||||
|
/// 窄入口(prefs / 导航 / rail 的那几个字段),rail 的内容一律走四个契约方法。
|
||||||
|
#[cfg(test)]
|
||||||
pub fn state_mut(&mut self) -> &mut AppState {
|
pub fn state_mut(&mut self) -> &mut AppState {
|
||||||
&mut self.state
|
&mut self.state
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 导航(一级页替换、子页压栈;去 AI 独立页会展开栏并清未读)。
|
||||||
|
pub fn navigate(&mut self, route: Route) {
|
||||||
|
self.state.navigate(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 回退一层(子页面);已在根页面时返回 `false`。
|
||||||
|
pub fn back(&mut self) -> bool {
|
||||||
|
self.state.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 折叠/展开 AI 栏(独立页形态下等价于收回侧栏;展开时清未读)。
|
||||||
|
pub fn toggle_ai(&mut self) {
|
||||||
|
self.state.toggle_ai();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI 偏好(主题、磁贴尺寸、栏折叠位…)。**不含** rail 字段。
|
||||||
|
pub fn prefs_mut(&mut self) -> &mut UiPrefs {
|
||||||
|
&mut self.state.prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// YOLO 开关(设置 → AI 的那一项)。
|
||||||
|
///
|
||||||
|
/// 只翻开关不裁决:若此刻正有待确认请求,调用方仍要走一次
|
||||||
|
/// [`Presenter::agent_confirm`](YOLO 下它会自动放行并记审计)。
|
||||||
|
pub fn agent_set_yolo(&mut self, on: bool) {
|
||||||
|
self.state.ai.yolo = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 输入框草稿(用户打字时同步进来,切形态不丢)。
|
||||||
|
pub fn agent_set_draft(&mut self, text: String) {
|
||||||
|
self.state.ai.draft = text;
|
||||||
|
}
|
||||||
|
|
||||||
/// 喂一条事件(纯转发给 [`AppState::apply`])。
|
/// 喂一条事件(纯转发给 [`AppState::apply`])。
|
||||||
pub fn apply(&mut self, event: AppEvent) {
|
pub fn apply(&mut self, event: AppEvent) {
|
||||||
self.state.apply(event);
|
self.state.apply(event);
|
||||||
@@ -168,8 +211,10 @@ impl Presenter {
|
|||||||
/// 拒绝的三种情形都不改状态(草稿保留,用户能接着改):
|
/// 拒绝的三种情形都不改状态(草稿保留,用户能接着改):
|
||||||
/// 空文本、上一轮还在跑、有写操作待确认。
|
/// 空文本、上一轮还在跑、有写操作待确认。
|
||||||
///
|
///
|
||||||
/// 成功时清空草稿并顺序投影后端返回的事件分片。用户气泡**由事件产生**
|
/// 成功时清空草稿、立刻把 `busy` 置真(**然后**才投影后端返回的事件分片),最后按消息
|
||||||
/// (不在本地乐观回显,见 `lib.rs` §3)。
|
/// 重算一次 `streaming`——异步后端在「提交成功 → 第一个事件到达」之间的空窗因此不会
|
||||||
|
/// 让用户再点一次发送(那会产生两条 user 消息、两轮回复)。
|
||||||
|
/// 用户气泡**由事件产生**(不在本地乐观回显,见 `lib.rs` §3)。
|
||||||
pub fn agent_submit(&mut self, text: String) -> Result<(), String> {
|
pub fn agent_submit(&mut self, text: String) -> Result<(), String> {
|
||||||
let trimmed = text.trim();
|
let trimmed = text.trim();
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
@@ -190,9 +235,11 @@ impl Presenter {
|
|||||||
.map_err(|error| scrub_text(&error))?;
|
.map_err(|error| scrub_text(&error))?;
|
||||||
self.state.ai.draft.clear();
|
self.state.ai.draft.clear();
|
||||||
self.state.ai.error = None;
|
self.state.ai.error = None;
|
||||||
|
self.state.ai.begin_turn();
|
||||||
for event in events {
|
for event in events {
|
||||||
self.state.apply(event);
|
self.state.apply(event);
|
||||||
}
|
}
|
||||||
|
self.state.ai.settle_streaming();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,14 @@
|
|||||||
//! [`scrub_text`] / [`scrub_details`]。凭据字段(`token` / `refresh_token` / `password` /
|
//! [`scrub_text`] / [`scrub_details`]。凭据字段(`token` / `refresh_token` / `password` /
|
||||||
//! `access_token` …)不该出现在事件里(AGENTS §6:AI 永不接触账户凭据),这里的脱敏是
|
//! `access_token` …)不该出现在事件里(AGENTS §6:AI 永不接触账户凭据),这里的脱敏是
|
||||||
//! **第二道**防线:万一实现方把 JWT 塞进了工具摘要或错误文案,rail 上也不会出现原文。
|
//! **第二道**防线:万一实现方把 JWT 塞进了工具摘要或错误文案,rail 上也不会出现原文。
|
||||||
//! 复用 `unlucky_core::launch::redact`(M40 上提的「带点 token」规则),不另写一套正则。
|
//!
|
||||||
|
//! 规则来源(刻意不另写一套正则):
|
||||||
|
//! - 内容侧:复用 `unlucky_core::launch::redact`(M40 上提的「带点 token」+ ≥64 不透明串);
|
||||||
|
//! - 上下文侧:照 `unlucky_agent::redact` 补两条——`Bearer <短令牌>` 掩码、
|
||||||
|
//! `{"path": 敏感键}` 时 `value`/`old`/`new` 一并掩码(`set_config` 的 details 正是它)。
|
||||||
|
//!
|
||||||
|
//! 键名名单与 `unlucky_agent::redact::is_sensitive_key` **当前逐字相同**;差异在上下文规则
|
||||||
|
//! 上(agent 侧多一条 path 规则,presenter 已补齐,见上)。漂移风险记在 M103 报告里。
|
||||||
//!
|
//!
|
||||||
//! # 3. 刻意不做的事
|
//! # 3. 刻意不做的事
|
||||||
//!
|
//!
|
||||||
@@ -63,27 +70,39 @@ pub fn is_sensitive_key(key: &str) -> bool {
|
|||||||
.any(|needle| lower.contains(needle))
|
.any(|needle| lower.contains(needle))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 文本脱敏:JWT 形状(带点 token)/ ≥64 不透明串由 core 的规则打码。
|
/// 文本脱敏:内容规则三件套。
|
||||||
///
|
///
|
||||||
/// 不登记主目录(rail 不持有数据根):`<home>` 掩码是 host 侧的职责。
|
/// 1. JWT 形状(带点 token)/ ≥64 不透明串 —— 复用 core 的 `Redactor`(M40 上提的那份);
|
||||||
|
/// 2. `Bearer` 后面的短令牌 —— core 没有这条,照 `unlucky_agent::redact::mask_bearer` 补;
|
||||||
|
/// 3. 主目录不登记:rail 不持有数据根,`<home>` 掩码是 host 侧的职责。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn scrub_text(text: &str) -> String {
|
pub fn scrub_text(text: &str) -> String {
|
||||||
Redactor::new().scrub(text).into_owned()
|
mask_bearer(&Redactor::new().scrub(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 结构化细节脱敏:先按键名掩码,再按内容掩码(两道都要,见模块文档 §2)。
|
/// 结构化细节脱敏:按**键名**、按**值随 path 敏感**、按内容三道(见模块文档 §2)。
|
||||||
|
///
|
||||||
|
/// 同样对齐 `unlucky_agent::redact::redact_args` 的两条上下文规则:键名命中即掩码,
|
||||||
|
/// 以及 `{"path": "ai.api_key", "old": …, "new": …}` 这种「值随路径敏感」的形状
|
||||||
|
/// (`set_config` 的 details 正是它)——否则 API key 会原样躺在确认弹窗的 diff 里。
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn scrub_details(value: &Value) -> Value {
|
pub fn scrub_details(value: &Value) -> Value {
|
||||||
match value {
|
match value {
|
||||||
Value::Object(map) => {
|
Value::Object(map) => {
|
||||||
|
let sensitive_path = map
|
||||||
|
.get("path")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(is_sensitive_key);
|
||||||
let mut out = Map::with_capacity(map.len());
|
let mut out = Map::with_capacity(map.len());
|
||||||
for (key, child) in map {
|
for (key, child) in map {
|
||||||
let masked = if is_sensitive_key(key) {
|
let masked = is_sensitive_key(key)
|
||||||
|
|| (sensitive_path && matches!(key.as_str(), "value" | "old" | "new"));
|
||||||
|
let child = if masked {
|
||||||
Value::String(TOKEN_PLACEHOLDER.to_string())
|
Value::String(TOKEN_PLACEHOLDER.to_string())
|
||||||
} else {
|
} else {
|
||||||
scrub_details(child)
|
scrub_details(child)
|
||||||
};
|
};
|
||||||
out.insert(key.clone(), masked);
|
out.insert(key.clone(), child);
|
||||||
}
|
}
|
||||||
Value::Object(out)
|
Value::Object(out)
|
||||||
}
|
}
|
||||||
@@ -93,6 +112,38 @@ pub fn scrub_details(value: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `Bearer` 之后的分隔符(令牌到此为止)。
|
||||||
|
const BEARER_STOPS: [char; 8] = [' ', '\t', '\n', '\r', '"', '\'', ',', ';'];
|
||||||
|
|
||||||
|
/// `Bearer <token>` → `Bearer <redacted:token>`。
|
||||||
|
///
|
||||||
|
/// core 的 `Redactor` 只认「点号 token / ≥64 不透明串」,短令牌(`Bearer abcdef123456`)
|
||||||
|
/// 会原样放过——这条规则照 `unlucky_agent::redact::mask_bearer` 补上。幂等:已经是
|
||||||
|
/// `<redacted:token>` 的再跑一遍结果不变。
|
||||||
|
fn mask_bearer(text: &str) -> String {
|
||||||
|
const MARKER: &str = "Bearer ";
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
while let Some(position) = text[cursor..].find(MARKER) {
|
||||||
|
let token_start = cursor + position + MARKER.len();
|
||||||
|
let token_end = bearer_token_end(text, token_start);
|
||||||
|
out.push_str(&text[cursor..token_start]);
|
||||||
|
if token_end > token_start {
|
||||||
|
out.push_str(TOKEN_PLACEHOLDER);
|
||||||
|
}
|
||||||
|
cursor = token_end;
|
||||||
|
}
|
||||||
|
out.push_str(&text[cursor..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 `from` 起取到下一个分隔符为止(令牌的右边界)。
|
||||||
|
fn bearer_token_end(text: &str, from: usize) -> usize {
|
||||||
|
text[from..]
|
||||||
|
.find(BEARER_STOPS)
|
||||||
|
.map_or(text.len(), |offset| from + offset)
|
||||||
|
}
|
||||||
|
|
||||||
/// AI 助手栏视图模型(UI 壳只读它,不自己攒会话状态)。
|
/// AI 助手栏视图模型(UI 壳只读它,不自己攒会话状态)。
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct AgentRail {
|
pub struct AgentRail {
|
||||||
@@ -144,7 +195,22 @@ impl AgentRail {
|
|||||||
for message in &mut self.messages {
|
for message in &mut self.messages {
|
||||||
message.streaming = false;
|
message.streaming = false;
|
||||||
}
|
}
|
||||||
self.streaming = false;
|
self.settle_streaming();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交成功、后端还没回事件时先把「一轮在跑」点亮。
|
||||||
|
///
|
||||||
|
/// 异步后端在「提交成功 → 第一个事件到达」之间有空窗;不先置忙的话用户能再点一次发送,
|
||||||
|
/// 于是两条 user 消息、两轮回复并行。反正事件流收敛时会用
|
||||||
|
/// [`AgentRail::settle_streaming`] 按消息重算,不会留下假忙。
|
||||||
|
pub fn begin_turn(&mut self) {
|
||||||
|
self.streaming = true;
|
||||||
|
self.sync_busy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按消息重算 `streaming`(有任一条还在流式即为真)并同步 `busy`。
|
||||||
|
pub fn settle_streaming(&mut self) {
|
||||||
|
self.streaming = self.messages.iter().any(|message| message.streaming);
|
||||||
self.sync_busy();
|
self.sync_busy();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,8 +248,15 @@ impl AgentRail {
|
|||||||
AgentEvent::MessageDelta { index, delta } => {
|
AgentEvent::MessageDelta { index, delta } => {
|
||||||
// 只追加正文,不动流式标记:迟到的尾增量(`Failed` 之后)不该把
|
// 只追加正文,不动流式标记:迟到的尾增量(`Failed` 之后)不该把
|
||||||
// 「一轮在跑」重新点亮,否则输入框会一直禁用。
|
// 「一轮在跑」重新点亮,否则输入框会一直禁用。
|
||||||
if let Some(message) = self.messages.get_mut(*index) {
|
match self.messages.get_mut(*index) {
|
||||||
message.text.push_str(&scrub_text(delta));
|
Some(message) => message.text.push_str(&scrub_text(delta)),
|
||||||
|
// 分片乱序/重放时正文会无声消失(用户只看到半截话):开发期吵起来,
|
||||||
|
// 发布期宁可丢这一片也不去猜该拼到哪条上。
|
||||||
|
None => debug_assert!(
|
||||||
|
false,
|
||||||
|
"MessageDelta 指向不存在的消息下标 {index}(会话 {} 条)",
|
||||||
|
self.messages.len()
|
||||||
|
),
|
||||||
}
|
}
|
||||||
self.sync_busy();
|
self.sync_busy();
|
||||||
false
|
false
|
||||||
@@ -193,8 +266,7 @@ impl AgentRail {
|
|||||||
message.streaming = false;
|
message.streaming = false;
|
||||||
}
|
}
|
||||||
// 以消息为准重算:并发工具条目没收敛时 `streaming` 不能提前熄
|
// 以消息为准重算:并发工具条目没收敛时 `streaming` 不能提前熄
|
||||||
self.streaming = self.messages.iter().any(|message| message.streaming);
|
self.settle_streaming();
|
||||||
self.sync_busy();
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
AgentEvent::ToolCallStarted { name, summary } => {
|
AgentEvent::ToolCallStarted { name, summary } => {
|
||||||
@@ -217,18 +289,13 @@ impl AgentRail {
|
|||||||
AgentEvent::ToolCallFinished { name, ok, summary } => {
|
AgentEvent::ToolCallFinished { name, ok, summary } => {
|
||||||
let name = scrub_text(name);
|
let name = scrub_text(name);
|
||||||
let summary = scrub_text(summary);
|
let summary = scrub_text(summary);
|
||||||
// 优先收敛「还没结束的同名调用」(并发同名工具时按最近一条),
|
// 只收敛「还在跑的同名调用」。找不到就**忽略**,绝不退回改写已收敛的旧条目
|
||||||
// 找不到再退回最近一条同名条目(重放/重复结束事件)。
|
// ——那会把历史结果覆盖掉(重放、重复结束事件、丢过 ToolCallStarted 都会命中)。
|
||||||
let pending = self
|
let pending = self
|
||||||
.messages
|
.messages
|
||||||
.iter()
|
.iter()
|
||||||
.rposition(|message| message.streaming && tool_named(message, &name));
|
.rposition(|message| message.streaming && tool_named(message, &name));
|
||||||
let target = pending.or_else(|| {
|
if let Some(index) = pending {
|
||||||
self.messages
|
|
||||||
.iter()
|
|
||||||
.rposition(|message| tool_named(message, &name))
|
|
||||||
});
|
|
||||||
if let Some(index) = target {
|
|
||||||
let message = &mut self.messages[index];
|
let message = &mut self.messages[index];
|
||||||
message.streaming = false;
|
message.streaming = false;
|
||||||
message.text.clone_from(&summary);
|
message.text.clone_from(&summary);
|
||||||
@@ -237,8 +304,7 @@ impl AgentRail {
|
|||||||
tool.summary = summary;
|
tool.summary = summary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.streaming = self.messages.iter().any(|message| message.streaming);
|
self.settle_streaming();
|
||||||
self.sync_busy();
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
AgentEvent::Failed { message } => {
|
AgentEvent::Failed { message } => {
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
//! `agent` 模块的单元测试:四条路径(正常问答 / 流式中途出错 / 需确认的写操作 / 未读计数)+
|
//! `agent` 模块的单元测试:四条路径(正常问答 / 流式中途出错 / 需确认的写操作 / 未读计数)+
|
||||||
//! 红线(凭据形状的串不得进 rail)+ YOLO 语义 + 提交前置条件。
|
//! YOLO 语义 + 提交前置条件 + 投影健壮性。
|
||||||
//!
|
//!
|
||||||
|
//! 红线(凭据形状的串不得进 rail)单列在 `agent/tests_red_line.rs`(单文件 ≤400 行)。
|
||||||
//! 断言口径照 M103 的要求写清「抓得住什么 / 抓不住什么」:这些测试盯的是**投影与裁决**,
|
//! 断言口径照 M103 的要求写清「抓得住什么 / 抓不住什么」:这些测试盯的是**投影与裁决**,
|
||||||
//! 不盯真实模型的措辞、真实时序与网络错误——那些要接 rig 之后跑回放回归。
|
//! 不盯真实模型的措辞、真实时序与网络错误——那些要接 rig 之后跑回放回归。
|
||||||
|
|
||||||
use super::mock::{MockAgentTransport, Script};
|
use super::mock::{MockAgentTransport, Script};
|
||||||
use super::{AgentTransport, ConfirmVerdict, Presenter};
|
use super::{AgentTransport, ConfirmVerdict, Presenter};
|
||||||
use crate::agent::rail::scrub_text;
|
|
||||||
use crate::agent::AgentRail;
|
use crate::agent::AgentRail;
|
||||||
use crate::dto::{AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity};
|
use crate::dto::{AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity};
|
||||||
|
|
||||||
/// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。
|
|
||||||
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
|
|
||||||
|
|
||||||
fn presenter(script: Script) -> Presenter {
|
fn presenter(script: Script) -> Presenter {
|
||||||
Presenter::new(Box::new(MockAgentTransport::new(script)))
|
Presenter::new(Box::new(MockAgentTransport::new(script)))
|
||||||
}
|
}
|
||||||
@@ -172,65 +169,68 @@ fn pending_confirm_forces_busy_without_yolo() {
|
|||||||
presenter
|
presenter
|
||||||
.agent_submit("并发调小点".to_string())
|
.agent_submit("并发调小点".to_string())
|
||||||
.expect("提交应当成功");
|
.expect("提交应当成功");
|
||||||
|
assert!(presenter.agent_rail().confirm.is_some());
|
||||||
|
|
||||||
|
// 先把流式标记清干净:否则 `busy` 可能只是「streaming 顺手置真」,
|
||||||
|
// 这条测试就钉不住「待确认算忙」这条规则(r1 P2-2:变异掉 `confirm.is_some()`
|
||||||
|
// 之后这里必须变红)。
|
||||||
|
presenter.state_mut().ai.finish_stream();
|
||||||
let rail = presenter.agent_rail();
|
let rail = presenter.agent_rail();
|
||||||
assert!(rail.confirm.is_some());
|
assert!(!rail.streaming, "此刻没有流式消息在跑");
|
||||||
assert!(rail.busy, "非 YOLO 下待确认必须算忙,UI 靠它禁用输入");
|
assert!(rail.busy, "非 YOLO 下待确认必须算忙,UI 靠它禁用输入");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────────────────── 红线:凭据形状的串 ─────────────────────
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn jwt_shaped_text_never_reaches_the_rail() {
|
fn begin_turn_marks_busy_before_any_event_arrives() {
|
||||||
let mut presenter = presenter(Script::Qa);
|
// 提交成功到第一个事件之间异步后端有空窗:这段时间必须已经算忙,
|
||||||
// 五个入口各注入一次:用户提交、增量、工具摘要、错误、确认请求细节
|
// 否则用户能再点一次发送(两条 user 消息、两轮回复)。
|
||||||
presenter
|
let mut rail = AgentRail::new();
|
||||||
.agent_submit(format!("我的 token 是 {JWT}"))
|
rail.begin_turn();
|
||||||
.expect("提交应当成功");
|
assert!(rail.busy && rail.streaming);
|
||||||
presenter.apply(AppEvent::Agent(AgentEvent::MessageDelta {
|
|
||||||
index: 1,
|
|
||||||
delta: format!("顺手贴一个 {JWT}"),
|
|
||||||
}));
|
|
||||||
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
|
||||||
name: "read_game_log".to_string(),
|
|
||||||
summary: format!("Authorization: Bearer {JWT}"),
|
|
||||||
}));
|
|
||||||
presenter.apply(AppEvent::Agent(AgentEvent::Failed {
|
|
||||||
message: format!("401: access_token={JWT}"),
|
|
||||||
}));
|
|
||||||
presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
|
||||||
request: ConfirmRequest {
|
|
||||||
id: "req-jwt".to_string(),
|
|
||||||
tool: "set_config".to_string(),
|
|
||||||
summary: format!("把 access_token 改成 {JWT}"),
|
|
||||||
details: serde_json::json!({ "access_token": JWT, "note": JWT }),
|
|
||||||
sensitivity: Sensitivity::High,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
let rail = presenter.agent_rail();
|
// 空事件流也不会把输入框永久锁死:收敛时按消息重算
|
||||||
let text = rail_text(rail);
|
rail.settle_streaming();
|
||||||
assert!(
|
assert!(!rail.busy && !rail.streaming);
|
||||||
!text.contains("eyJhbGci"),
|
|
||||||
"rail 上不得出现 JWT 原文:{text}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
text.contains("<redacted:token>"),
|
|
||||||
"应当被脱敏成占位符:{text}"
|
|
||||||
);
|
|
||||||
// 凭据键名的值整段打码(不只是靠内容规则)
|
|
||||||
let details = &rail.confirm.as_ref().expect("确认请求仍在").details;
|
|
||||||
assert_eq!(details["access_token"], "<redacted:token>");
|
|
||||||
assert!(!details["note"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap_or_default()
|
|
||||||
.contains("eyJhbGci"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scrub_text_is_idempotent_on_already_redacted_text() {
|
fn submit_with_a_silent_backend_does_not_leave_the_shell_busy() {
|
||||||
let once = scrub_text(JWT);
|
let mut presenter = Presenter::new(Box::new(SilentTransport));
|
||||||
assert_eq!(scrub_text(&once), once);
|
presenter
|
||||||
|
.agent_submit("在吗".to_string())
|
||||||
|
.expect("提交应当成功");
|
||||||
|
assert!(
|
||||||
|
!presenter.agent_rail().busy,
|
||||||
|
"后端一个事件都没回,输入框必须重新可用"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 工具收尾事件早于开始事件(或重复投递):不许覆盖已收敛的历史条目(r1 P2-3)。
|
||||||
|
#[test]
|
||||||
|
fn tool_call_finished_without_a_pending_call_never_rewrites_history() {
|
||||||
|
let mut presenter = presenter(Script::Qa);
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||||
|
name: "set_config".to_string(),
|
||||||
|
summary: "修改 download.concurrency".to_string(),
|
||||||
|
}));
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallFinished {
|
||||||
|
name: "set_config".to_string(),
|
||||||
|
ok: true,
|
||||||
|
summary: "并发已改成 16".to_string(),
|
||||||
|
}));
|
||||||
|
// 重放 / 乱序:没有 pending 的收尾事件只能被忽略
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallFinished {
|
||||||
|
name: "set_config".to_string(),
|
||||||
|
ok: false,
|
||||||
|
summary: "另一次失败".to_string(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let tool = presenter.agent_rail().messages[0]
|
||||||
|
.tool
|
||||||
|
.as_ref()
|
||||||
|
.expect("工具条目");
|
||||||
|
assert_eq!(tool.summary, "并发已改成 16", "历史条目被改写了");
|
||||||
|
assert!(tool.ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────── 提交前置条件 ───────────────────────
|
// ─────────────────────── 提交前置条件 ───────────────────────
|
||||||
@@ -320,22 +320,15 @@ impl AgentTransport for BrokenTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 把 rail 上所有对用户可见的文本拼起来(红线断言用)。
|
/// 收下输入但一个事件都不回的后端(异步推流的极端形态:这一拍还没到)。
|
||||||
fn rail_text(rail: &AgentRail) -> String {
|
struct SilentTransport;
|
||||||
let mut out = String::new();
|
|
||||||
for message in &rail.messages {
|
impl AgentTransport for SilentTransport {
|
||||||
out.push_str(&message.text);
|
fn submit(&mut self, _text: &str, _history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||||
if let Some(tool) = &message.tool {
|
Ok(Vec::new())
|
||||||
out.push_str(&tool.name);
|
|
||||||
out.push_str(&tool.summary);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
out.push_str(rail.error.as_deref().unwrap_or_default());
|
|
||||||
out.push_str(&rail.endpoint_label);
|
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
|
||||||
if let Some(confirm) = &rail.confirm {
|
Vec::new()
|
||||||
out.push_str(&confirm.tool);
|
|
||||||
out.push_str(&confirm.summary);
|
|
||||||
out.push_str(&confirm.details.to_string());
|
|
||||||
}
|
}
|
||||||
out
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
//! 红线测试:凭据形状的串不得进 [`crate::agent::AgentRail`]。
|
||||||
|
//!
|
||||||
|
//! 与 `agent/tests.rs` 同源、拆文件只为守「单文件 ≤400 行」。这一组测试的目标是**守住闸门**:
|
||||||
|
//! 每条入口各自注入,任何一条把脱敏摘掉都必须变红(r1 复审发现三条入口是假绿,故单列一文件,
|
||||||
|
//! 改动这里时请照 r1 的做法逐入口造变异自证)。
|
||||||
|
//!
|
||||||
|
//! 注入的凭据形状两类:
|
||||||
|
//! - JWT(`header.payload.signature`,靠 core `Redactor` 的「带点 token」规则拦);
|
||||||
|
//! - 非 JWT 形状(`{"password":"hunter2"}`、短 `Bearer abcdef123456`,靠键名与 Bearer 规则拦)。
|
||||||
|
|
||||||
|
use super::mock::{MockAgentTransport, Script};
|
||||||
|
use super::Presenter;
|
||||||
|
use crate::agent::rail::scrub_text;
|
||||||
|
use crate::agent::AgentRail;
|
||||||
|
use crate::dto::{AgentEvent, AppEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity};
|
||||||
|
|
||||||
|
/// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。
|
||||||
|
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
|
||||||
|
|
||||||
|
fn presenter(script: Script) -> Presenter {
|
||||||
|
Presenter::new(Box::new(MockAgentTransport::new(script)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn jwt_shaped_text_never_reaches_the_rail() {
|
||||||
|
let mut presenter = presenter(Script::Qa);
|
||||||
|
// 五个入口各注入一次:用户提交、增量、工具摘要、错误、确认请求细节
|
||||||
|
presenter
|
||||||
|
.agent_submit(format!("我的 token 是 {JWT}"))
|
||||||
|
.expect("提交应当成功");
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::MessageDelta {
|
||||||
|
index: 1,
|
||||||
|
delta: format!("顺手贴一个 {JWT}"),
|
||||||
|
}));
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||||
|
name: "read_game_log".to_string(),
|
||||||
|
summary: format!("Authorization: Bearer {JWT}"),
|
||||||
|
}));
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::Failed {
|
||||||
|
message: format!("401: access_token={JWT}"),
|
||||||
|
}));
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
||||||
|
request: ConfirmRequest {
|
||||||
|
id: "req-jwt".to_string(),
|
||||||
|
tool: "set_config".to_string(),
|
||||||
|
summary: format!("把 access_token 改成 {JWT}"),
|
||||||
|
details: serde_json::json!({ "access_token": JWT, "note": JWT }),
|
||||||
|
sensitivity: Sensitivity::High,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let rail = presenter.agent_rail();
|
||||||
|
let text = rail_text(rail);
|
||||||
|
assert!(
|
||||||
|
!text.contains("eyJhbGci"),
|
||||||
|
"rail 上不得出现 JWT 原文:{text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("<redacted:token>"),
|
||||||
|
"应当被脱敏成占位符:{text}"
|
||||||
|
);
|
||||||
|
// 凭据键名的值整段打码(不只是靠内容规则)
|
||||||
|
let details = &rail.confirm.as_ref().expect("确认请求仍在").details;
|
||||||
|
assert_eq!(details["access_token"], "<redacted:token>");
|
||||||
|
assert!(!details["note"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.contains("eyJhbGci"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上一条测试里 `MessageAppended` 走的是 `agent_submit`(已先 scrub),踩不到 rail 自己的
|
||||||
|
/// `scrub_message`。这条**绕过 submit** 直接投递,钉住那条入口(r1 P1 变异 A)。
|
||||||
|
#[test]
|
||||||
|
fn message_appended_from_the_backend_is_scrubbed_inside_the_rail() {
|
||||||
|
let mut presenter = presenter(Script::Qa);
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||||
|
ChatMessage::user(format!("access_token={JWT}")),
|
||||||
|
)));
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage {
|
||||||
|
role: ChatRole::Assistant,
|
||||||
|
text: format!("听到你贴了 {JWT}"),
|
||||||
|
tool: None,
|
||||||
|
streaming: false,
|
||||||
|
})));
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(
|
||||||
|
ChatMessage::system(format!("refresh_token={JWT}")),
|
||||||
|
)));
|
||||||
|
|
||||||
|
let text = rail_text(presenter.agent_rail());
|
||||||
|
assert!(
|
||||||
|
!text.contains("eyJhbGci"),
|
||||||
|
"MessageAppended 入口未脱敏:{text}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
presenter.agent_rail().messages.len(),
|
||||||
|
3,
|
||||||
|
"三条消息都要在(脱敏不等于丢消息)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 工具**收尾**事件(`ToolCallFinished`)同样带 name + summary,必须过闸门(r1 P1 变异 C2)。
|
||||||
|
///
|
||||||
|
/// 两边用同一个「名字里带 JWT」的工具名:脱敏后仍然相等,所以**只要有一侧没脱敏**,
|
||||||
|
/// 名字就对不上(条目收敛不了、`ok` 仍是 false),断言会红。
|
||||||
|
#[test]
|
||||||
|
fn tool_call_finished_summary_is_scrubbed_inside_the_rail() {
|
||||||
|
let mut presenter = presenter(Script::Qa);
|
||||||
|
let name = format!("set_config_{JWT}");
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||||
|
name: name.clone(),
|
||||||
|
summary: "修改 download.concurrency".to_string(),
|
||||||
|
}));
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallFinished {
|
||||||
|
name,
|
||||||
|
ok: true,
|
||||||
|
summary: format!("写进去了:Authorization: Bearer {JWT}"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let text = rail_text(presenter.agent_rail());
|
||||||
|
assert!(
|
||||||
|
!text.contains("eyJhbGci"),
|
||||||
|
"ToolCallFinished 入口未脱敏:{text}"
|
||||||
|
);
|
||||||
|
let tool = presenter.agent_rail().messages[0]
|
||||||
|
.tool
|
||||||
|
.as_ref()
|
||||||
|
.expect("工具条目");
|
||||||
|
assert!(tool.ok, "结论位要照实写");
|
||||||
|
assert!(!tool.name.contains("eyJhbGci"), "工具名也要过闸门");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非 JWT 形状的凭据(`{"password":"hunter2"}`)只能靠**键名**规则拦(r1 P1 变异 H)。
|
||||||
|
///
|
||||||
|
/// 同时钉住「值随 path 敏感」这条上下文规则(`set_config` 的 details 形状)。
|
||||||
|
#[test]
|
||||||
|
fn sensitive_keys_in_confirm_details_are_masked_without_jwt_shape() {
|
||||||
|
let mut presenter = presenter(Script::Qa);
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
||||||
|
request: ConfirmRequest {
|
||||||
|
id: "req-keys".to_string(),
|
||||||
|
tool: "set_config".to_string(),
|
||||||
|
summary: "改几个设置".to_string(),
|
||||||
|
details: serde_json::json!({
|
||||||
|
"password": "hunter2",
|
||||||
|
"api_key": "short-key-123",
|
||||||
|
"nested": { "refresh_token": "rt-1" },
|
||||||
|
"list": [{ "token": "xyz" }],
|
||||||
|
"plain": "nothing here",
|
||||||
|
"path": "ai.api_key",
|
||||||
|
"old": "",
|
||||||
|
"new": "sk-proj-abcdefghijklmnop",
|
||||||
|
}),
|
||||||
|
sensitivity: Sensitivity::High,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let details = &presenter
|
||||||
|
.agent_rail()
|
||||||
|
.confirm
|
||||||
|
.as_ref()
|
||||||
|
.expect("确认请求")
|
||||||
|
.details;
|
||||||
|
let placeholder = serde_json::json!("<redacted:token>");
|
||||||
|
// 键名命中:对象 / 嵌套 / 数组各一
|
||||||
|
assert_eq!(details["password"], placeholder);
|
||||||
|
assert_eq!(details["api_key"], placeholder);
|
||||||
|
assert_eq!(details["nested"]["refresh_token"], placeholder);
|
||||||
|
assert_eq!(details["list"][0]["token"], placeholder);
|
||||||
|
// 值随 path 敏感:old/new 一并掩码(不然 API key 就躺在确认弹窗的 diff 里)
|
||||||
|
assert_eq!(details["new"], placeholder);
|
||||||
|
assert_eq!(details["old"], placeholder);
|
||||||
|
// 无关键原样保留(审计要看得出改的是什么)
|
||||||
|
assert_eq!(details["plain"], serde_json::json!("nothing here"));
|
||||||
|
assert_eq!(details["path"], serde_json::json!("ai.api_key"));
|
||||||
|
|
||||||
|
let text = rail_text(presenter.agent_rail());
|
||||||
|
for leak in [
|
||||||
|
"hunter2",
|
||||||
|
"short-key-123",
|
||||||
|
"rt-1",
|
||||||
|
"xyz",
|
||||||
|
"sk-proj-abcdefghijklmnop",
|
||||||
|
] {
|
||||||
|
assert!(!text.contains(leak), "凭据泄漏到 rail:{leak} / {text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 短 `Bearer` 令牌不是 JWT 形状,core 的 `Redactor` 会放过它(r1 P2-1)。
|
||||||
|
#[test]
|
||||||
|
fn short_bearer_token_is_masked() {
|
||||||
|
let mut presenter = presenter(Script::Qa);
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::ToolCallStarted {
|
||||||
|
name: "read_game_log".to_string(),
|
||||||
|
summary: "Authorization: Bearer abcdef123456".to_string(),
|
||||||
|
}));
|
||||||
|
let text = rail_text(presenter.agent_rail());
|
||||||
|
assert!(
|
||||||
|
!text.contains("abcdef123456"),
|
||||||
|
"短 Bearer 令牌要掩码:{text}"
|
||||||
|
);
|
||||||
|
assert!(text.contains("Bearer <redacted:token>"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrub_text_is_idempotent_on_already_redacted_text() {
|
||||||
|
let once = scrub_text(JWT);
|
||||||
|
assert_eq!(scrub_text(&once), once);
|
||||||
|
let bearer = scrub_text("Authorization: Bearer abcdef123456");
|
||||||
|
assert_eq!(scrub_text(&bearer), bearer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把 rail 上所有对用户可见的文本拼起来(红线断言用)。
|
||||||
|
fn rail_text(rail: &AgentRail) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for message in &rail.messages {
|
||||||
|
out.push_str(&message.text);
|
||||||
|
if let Some(tool) = &message.tool {
|
||||||
|
out.push_str(&tool.name);
|
||||||
|
out.push_str(&tool.summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rail.error.as_deref().unwrap_or_default());
|
||||||
|
out.push_str(&rail.endpoint_label);
|
||||||
|
if let Some(confirm) = &rail.confirm {
|
||||||
|
out.push_str(&confirm.tool);
|
||||||
|
out.push_str(&confirm.summary);
|
||||||
|
out.push_str(&confirm.details.to_string());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -270,6 +270,9 @@ fn failed_event_settles_streaming_and_keeps_the_evidence() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn confirm_request_keeps_the_shell_busy_until_it_resolves() {
|
fn confirm_request_keeps_the_shell_busy_until_it_resolves() {
|
||||||
|
// 分工:这条钉 state 层的「待确认 ⇒ busy」与 `ConfirmResolved` 收敛;
|
||||||
|
// presenter 层的同名守卫是 `agent::tests::pending_confirm_forces_busy_without_yolo`
|
||||||
|
// (r1 P2-2:那条曾因 ToolCallStarted 顺手置真而空过,已改成先 finish_stream 再断言)。
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
state.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
state.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
||||||
request: confirm_request("req-1"),
|
request: confirm_request("req-1"),
|
||||||
|
|||||||
Reference in new issue
Block a user