test(presenter): M103 r2 —— 出站脱敏补守卫,另三处站点与 Bearer 右边界
P1(守卫缺失,产品代码是对的): - 新增 RecordingTransport + outbound_prompt_is_scrubbed_before_it_reaches_the_backend: 断言真正发给后端的那份 prompt 里没有 JWT(rail 侧也脱敏,「界面干净」证明不了出站安全) - 变异:删掉 agent_submit 的出站 scrub ⇒ 该测试红(输出见完成报告) 另三处同类站点(r2 P2-1): - confirm_tool_name_is_scrubbed(ConfirmRequest.tool) - tool_message_from_the_backend_has_its_tool_view_scrubbed(MessageAppended 的 ToolCallView 分支) - outbound_error_text_is_scrubbed(Err 文案,provider 401 常回显 key) - 三条各自变异后都变红 其他: - bearer_right_boundary_is_pinned(tests.rs):钉住 mask_bearer 的停字符集,收窄成 [' '] 即红 - 文档订正:rail.rs 模块 §2 改成「两道闸门(出站/显示)各守一侧」+ 与 agent 侧的实际关系 (名单逐字相同、path 语义相同、mask_bearer 是行为不完全等价的自写迁移;重复实现是欠账, 不读成「已无差异」也不读成「已经漂移」) - agent_set_draft 与 rail_text 各写明「draft 不在断言覆盖范围」及原因 - tests_red_line.rs 4 行 405 → 380;各文件均 ≤400
This commit is contained in:
1 parent
06461df91b
commit
a2b1a06901
4 files changed
+222
-17
No files matched your search
@@ -191,6 +191,10 @@ impl Presenter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 输入框草稿(用户打字时同步进来,切形态不丢)。
|
/// 输入框草稿(用户打字时同步进来,切形态不丢)。
|
||||||
|
///
|
||||||
|
/// 草稿**是** rail 上唯一能合法躺着凭据原文的字段:它是「还没发出去的字」,写进来时不过
|
||||||
|
/// 脱敏(UI 要能原样显示用户正在打的字)。安全性靠另一条:`agent_submit` 脱敏的是**入参**
|
||||||
|
/// 而不是草稿,所以原文既不会进会话历史,也不会被发给后端。
|
||||||
pub fn agent_set_draft(&mut self, text: String) {
|
pub fn agent_set_draft(&mut self, text: String) {
|
||||||
self.state.ai.draft = text;
|
self.state.ai.draft = text;
|
||||||
}
|
}
|
||||||
@@ -226,12 +230,16 @@ impl Presenter {
|
|||||||
if self.state.ai.busy {
|
if self.state.ai.busy {
|
||||||
return Err("上一轮回复还没结束".to_string());
|
return Err("上一轮回复还没结束".to_string());
|
||||||
}
|
}
|
||||||
// 用户输入也过脱敏:他可能顺手把 token 粘进对话框,那段文本会进会话历史、
|
// 用户输入也过脱敏:他可能顺手把 token 粘进对话框,而这段文本会进会话历史、
|
||||||
// 也会进后续 prompt 与导出。
|
// 会作为 prompt **发到模型服务商**、也会进导出。这道是**出站**闸门(rail 侧那道是
|
||||||
|
// 显示闸门,两者不能互相替代:删掉这道,界面照样干净,但凭据已经出去了)。
|
||||||
|
// 守卫见 agent/tests_red_line.rs::outbound_prompt_is_scrubbed_before_it_reaches_the_backend。
|
||||||
let text = scrub_text(trimmed);
|
let text = scrub_text(trimmed);
|
||||||
let events = self
|
let events = self
|
||||||
.transport
|
.transport
|
||||||
.submit(&text, &self.state.ai)
|
.submit(&text, &self.state.ai)
|
||||||
|
// 失败原因同样可能回显 key(provider 的 401 常把 key 抄进 message),
|
||||||
|
// 这条 Err 会直接显示在 UI 上,守卫见 outbound_error_text_is_scrubbed。
|
||||||
.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;
|
||||||
|
|||||||
@@ -20,20 +20,38 @@
|
|||||||
//! | `Failed` | 收敛流式 + 立横幅 + 追加系统条目(错误也要能回翻) |
|
//! | `Failed` | 收敛流式 + 立横幅 + 追加系统条目(错误也要能回翻) |
|
||||||
//! | `ConfirmRequested` / `ConfirmResolved` | `rail.confirm` 置位 / 清除 |
|
//! | `ConfirmRequested` / `ConfirmResolved` | `rail.confirm` 置位 / 清除 |
|
||||||
//!
|
//!
|
||||||
//! # 2. 红线:进入 rail 的文本一律脱敏
|
//! # 2. 红线:凭据不得进 rail,也不得**出站**
|
||||||
//!
|
//!
|
||||||
//! [`AgentRail::ingest`] 是所有 agent 文本进入 rail 的**唯一闸门**,它逐字段过
|
//! 两道闸门,各守一侧,**不能互相替代**:
|
||||||
//! [`scrub_text`] / [`scrub_details`]。凭据字段(`token` / `refresh_token` / `password` /
|
//!
|
||||||
//! `access_token` …)不该出现在事件里(AGENTS §6:AI 永不接触账户凭据),这里的脱敏是
|
//! | 闸门 | 位置 | 守什么 |
|
||||||
//! **第二道**防线:万一实现方把 JWT 塞进了工具摘要或错误文案,rail 上也不会出现原文。
|
//! | ---- | ---- | ------ |
|
||||||
|
//! | 出站 | [`crate::agent::Presenter::agent_submit`] 的 `scrub_text(trimmed)` / `map_err` | 发给后端的 prompt、回给 UI 的 `Err` 文案 |
|
||||||
|
//! | 显示 | [`AgentRail::ingest`] 这里 | 进入 rail 的所有事件文本(消息正文 / 工具名与摘要 / 错误 / 确认请求) |
|
||||||
|
//!
|
||||||
|
//! 只留出站闸门 → 后端事件里的凭据会直接显示在聊天栏;只留显示闸门 → 界面一片干净而
|
||||||
|
//! **凭据已经发出去了**。两边各有独立守卫(`agent/tests_red_line.rs`)。
|
||||||
|
//!
|
||||||
|
//! [`AgentRail::ingest`] 逐字段过 [`scrub_text`] / [`scrub_details`]。凭据字段(`token` /
|
||||||
|
//! `refresh_token` / `password` / `access_token` …)本就不该出现在事件里(AGENTS §6:AI 永不
|
||||||
|
//! 接触账户凭据),这里的脱敏是**第二道**防线:万一实现方把 JWT 塞进了工具摘要或错误文案,
|
||||||
|
//! rail 上也不会出现原文。
|
||||||
//!
|
//!
|
||||||
//! 规则来源(刻意不另写一套正则):
|
//! 规则来源(刻意不另写一套正则):
|
||||||
//! - 内容侧:复用 `unlucky_core::launch::redact`(M40 上提的「带点 token」+ ≥64 不透明串);
|
//! - 内容侧:复用 `unlucky_core::launch::redact`(M40 上提的「带点 token」+ ≥64 不透明串);
|
||||||
//! - 上下文侧:照 `unlucky_agent::redact` 补两条——`Bearer <短令牌>` 掩码、
|
//! - 上下文侧:照 `unlucky_agent::redact` 补两条——`Bearer <短令牌>` 掩码、
|
||||||
//! `{"path": 敏感键}` 时 `value`/`old`/`new` 一并掩码(`set_config` 的 details 正是它)。
|
//! `{"path": 敏感键}` 时 `value`/`old`/`new` 一并掩码(`set_config` 的 details 正是它)。
|
||||||
//!
|
//!
|
||||||
//! 键名名单与 `unlucky_agent::redact::is_sensitive_key` **当前逐字相同**;差异在上下文规则
|
//! 与 `unlucky_agent::redact` 的实际关系(**别读成「已无差异」,也别读成「已经漂移」**):
|
||||||
//! 上(agent 侧多一条 path 规则,presenter 已补齐,见上)。漂移风险记在 M103 报告里。
|
//! - 键名名单(8 条 needle):**逐字相同**;
|
||||||
|
//! - path 规则:**语义相同**(presenter 额外对字符串值跑 `scrub_text`,比 `redact_args` 更严);
|
||||||
|
//! - [`mask_bearer`]:**行为不完全等价的自写迁移**——停字符集 presenter 8 个(含 `;`,
|
||||||
|
//! 不含 `/` `\`)vs agent `segment_end` 9 个(含 `/` `\`,不含 `;`),占位符也不同
|
||||||
|
//! (`<redacted:token>` vs `***`)。`Bearer abc;def` presenter 掩得更少(`abc` 被掩、
|
||||||
|
//! `;def` 留着),`Bearer abc/def` 掩得更多。方向安全(多掩不漏;少掩只发生在 `;` 上),
|
||||||
|
//! 右边界由 `tests::bearer_right_boundary_is_pinned` 钉住。
|
||||||
|
//! - 两处重复实现(键名名单 + Bearer)是**已知欠账**:上提一份公共实现要单开 mission
|
||||||
|
//! (presenter 不能依赖 `unlucky-agent`,那会拖进 rig / tokio / rmcp)。
|
||||||
//!
|
//!
|
||||||
//! # 3. 刻意不做的事
|
//! # 3. 刻意不做的事
|
||||||
//!
|
//!
|
||||||
@@ -112,14 +130,15 @@ pub fn scrub_details(value: &Value) -> Value {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `Bearer` 之后的分隔符(令牌到此为止)。
|
/// `Bearer` 之后的分隔符(令牌到此为止)。与 agent `segment_end` 的差异见模块文档 §2:
|
||||||
|
/// 多 `;`、少 `/` `\`(方向安全:多掩不漏),右边界由 `tests::bearer_right_boundary_is_pinned` 钉住。
|
||||||
const BEARER_STOPS: [char; 8] = [' ', '\t', '\n', '\r', '"', '\'', ',', ';'];
|
const BEARER_STOPS: [char; 8] = [' ', '\t', '\n', '\r', '"', '\'', ',', ';'];
|
||||||
|
|
||||||
/// `Bearer <token>` → `Bearer <redacted:token>`。
|
/// `Bearer <token>` → `Bearer <redacted:token>`。
|
||||||
///
|
///
|
||||||
/// core 的 `Redactor` 只认「点号 token / ≥64 不透明串」,短令牌(`Bearer abcdef123456`)
|
/// core 的 `Redactor` 只认「点号 token / ≥64 不透明串」,短令牌(`Bearer abcdef123456`)
|
||||||
/// 会原样放过——这条规则照 `unlucky_agent::redact::mask_bearer` 补上。幂等:已经是
|
/// 会原样放过——这条规则照 `unlucky_agent::redact::mask_bearer` 的意图补上(实现是自写的,
|
||||||
/// `<redacted:token>` 的再跑一遍结果不变。
|
/// 见模块文档 §2 的差异说明)。幂等:已经是 `<redacted:token>` 的再跑一遍结果不变。
|
||||||
fn mask_bearer(text: &str) -> String {
|
fn mask_bearer(text: &str) -> String {
|
||||||
const MARKER: &str = "Bearer ";
|
const MARKER: &str = "Bearer ";
|
||||||
let mut out = String::with_capacity(text.len());
|
let mut out = String::with_capacity(text.len());
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
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};
|
||||||
|
|
||||||
@@ -233,6 +234,33 @@ fn tool_call_finished_without_a_pending_call_never_rewrites_history() {
|
|||||||
assert!(tool.ok);
|
assert!(tool.ok);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────── scrub_text 的 Bearer 右边界 ──────────────────
|
||||||
|
|
||||||
|
/// 钉住 `mask_bearer` 的**右边界**(r2 P2-2:停字符集收窄或换字符都必须让这条红)。
|
||||||
|
///
|
||||||
|
/// 方向说明:停字符越少 = 吞得越多(安全);放宽才会裸奔。所以句点故意不在停字符集里
|
||||||
|
/// (吞掉句点,换「短 JWT 只掩前半段」是不划算的)。
|
||||||
|
#[test]
|
||||||
|
fn bearer_right_boundary_is_pinned() {
|
||||||
|
// 空白 / 逗号 / 分号 / 引号是停字符:令牌之后的内容原样保留
|
||||||
|
assert_eq!(
|
||||||
|
scrub_text("Authorization: Bearer abc123, retry=1"),
|
||||||
|
"Authorization: Bearer <redacted:token>, retry=1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scrub_text("Bearer abc123; rest"),
|
||||||
|
"Bearer <redacted:token>; rest"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
scrub_text("\"Bearer abc123\""),
|
||||||
|
"\"Bearer <redacted:token>\""
|
||||||
|
);
|
||||||
|
assert_eq!(scrub_text("Bearer abc123"), "Bearer <redacted:token>");
|
||||||
|
// 句点不是停字符:整段吞掉(宁可多掩,也不让短 JWT 只掩前半段)
|
||||||
|
assert_eq!(scrub_text("Bearer abc123."), "Bearer <redacted:token>");
|
||||||
|
assert_eq!(scrub_text("Bearer eyJ.abc.def"), "Bearer <redacted:token>");
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────── 提交前置条件 ───────────────────────
|
// ─────────────────────── 提交前置条件 ───────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
//! 红线测试:凭据形状的串不得进 [`crate::agent::AgentRail`]。
|
//! 红线测试:凭据形状的串不得进 [`crate::agent::AgentRail`],也不得**出站**。
|
||||||
//!
|
//!
|
||||||
//! 与 `agent/tests.rs` 同源、拆文件只为守「单文件 ≤400 行」。这一组测试的目标是**守住闸门**:
|
//! 与 `agent/tests.rs` 同源、拆文件只为守「单文件 ≤400 行」。这一组测试的目标是**守住闸门**:
|
||||||
//! 每条入口各自注入,任何一条把脱敏摘掉都必须变红(r1 复审发现三条入口是假绿,故单列一文件,
|
//! 两道闸门(出站 [`Presenter::agent_submit`] 的 prompt/Err、显示 [`crate::agent::rail`] 的
|
||||||
//! 改动这里时请照 r1 的做法逐入口造变异自证)。
|
//! ingest 投影)与每条具体入口各自注入一次,任何一条把脱敏摘掉都必须变红。
|
||||||
|
//! r1 / r2 复审都是靠「逐入口造变异」抓出假绿的,改动这里时请照做自证。
|
||||||
//!
|
//!
|
||||||
//! 注入的凭据形状两类:
|
//! 注入的凭据形状两类:
|
||||||
//! - JWT(`header.payload.signature`,靠 core `Redactor` 的「带点 token」规则拦);
|
//! - JWT(`header.payload.signature`,靠 core `Redactor` 的「带点 token」规则拦);
|
||||||
//! - 非 JWT 形状(`{"password":"hunter2"}`、短 `Bearer abcdef123456`,靠键名与 Bearer 规则拦)。
|
//! - 非 JWT 形状(`{"password":"hunter2"}`、短 `Bearer abcdef123456`,靠键名与 Bearer 规则拦)。
|
||||||
|
//!
|
||||||
|
//! **`rail.draft` 不在这组断言的覆盖范围**:草稿是「还没发出去的字」,UI 要原样显示,
|
||||||
|
//! 它按设计可以躺着凭据原文;安全性由「`agent_submit` 脱敏的是入参而非草稿」保证。
|
||||||
|
//! 所以本文件的 `rail_text()` 刻意不含 draft——别把「rail 全文无 JWT」读成覆盖了 draft。
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use super::mock::{MockAgentTransport, Script};
|
use super::mock::{MockAgentTransport, Script};
|
||||||
use super::Presenter;
|
use super::{AgentTransport, Presenter};
|
||||||
use crate::agent::rail::scrub_text;
|
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, ToolCallView,
|
||||||
|
};
|
||||||
|
|
||||||
/// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。
|
/// 真实形状的 JWT(header.payload.signature,三段各 ≥8)。
|
||||||
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
|
const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.c2lnbmF0dXJlLXZhbHVlLXRoYXQtaXMtbG9uZy1lbm91Z2g";
|
||||||
@@ -209,7 +219,147 @@ fn scrub_text_is_idempotent_on_already_redacted_text() {
|
|||||||
assert_eq!(scrub_text(&bearer), bearer);
|
assert_eq!(scrub_text(&bearer), bearer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────── 出站闸门:发给后端的那份 payload ─────────────────
|
||||||
|
|
||||||
|
/// 记录 `submit` 实参的后端替身:红线里最重要的是「**真正发出去的是什么**」,
|
||||||
|
/// 而不是 rail 上显示成什么(显示层自己还会再脱一次,会把出站的漏洞盖住)。
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct RecordingTransport {
|
||||||
|
seen: Rc<RefCell<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentTransport for RecordingTransport {
|
||||||
|
fn submit(&mut self, text: &str, _history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||||
|
self.seen.borrow_mut().push(text.to_string());
|
||||||
|
// 照常回一条用户气泡,让整轮流程跟真实后端一致(rail 会再脱一次,所以
|
||||||
|
// 只断言 rail 干净是抓不住出站泄漏的)
|
||||||
|
Ok(vec![AppEvent::Agent(AgentEvent::MessageAppended(
|
||||||
|
ChatMessage::user(text),
|
||||||
|
))])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一律报错的后端替身(用来守 Err 文案那道 scrub)。
|
||||||
|
struct ErrorTransport(String);
|
||||||
|
|
||||||
|
impl AgentTransport for ErrorTransport {
|
||||||
|
fn submit(&mut self, _text: &str, _history: &AgentRail) -> Result<Vec<AppEvent>, String> {
|
||||||
|
Err(self.0.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm(&mut self, _request_id: &str, _accept: bool) -> Vec<AppEvent> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 出站守卫(r2 P1):`agent_submit` 送去后端的那句 prompt 必须已经脱敏。
|
||||||
|
///
|
||||||
|
/// 关键在**看后端收到什么**:rail 侧也会脱敏,所以「界面上没有 JWT」证明不了出站安全。
|
||||||
|
#[test]
|
||||||
|
fn outbound_prompt_is_scrubbed_before_it_reaches_the_backend() {
|
||||||
|
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let mut presenter = Presenter::new(Box::new(RecordingTransport {
|
||||||
|
seen: Rc::clone(&seen),
|
||||||
|
}));
|
||||||
|
presenter
|
||||||
|
.agent_submit(format!("帮我看看这个 token:{JWT}"))
|
||||||
|
.expect("提交应当成功");
|
||||||
|
|
||||||
|
let seen = seen.borrow();
|
||||||
|
assert_eq!(seen.len(), 1, "一轮输入只该发一次");
|
||||||
|
assert!(
|
||||||
|
!seen[0].contains("eyJhbGci"),
|
||||||
|
"出站 prompt 里还有 JWT 原文:{}",
|
||||||
|
seen[0]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
seen[0].contains("<redacted:token>"),
|
||||||
|
"出站 prompt 应当带占位符:{}",
|
||||||
|
seen[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 失败原因(provider 的 401 常把 key 抄进 message)同样是 UI 可见文本(r2 P2-1 / N)。
|
||||||
|
#[test]
|
||||||
|
fn outbound_error_text_is_scrubbed() {
|
||||||
|
let mut presenter = Presenter::new(Box::new(ErrorTransport(format!(
|
||||||
|
"401 Unauthorized: key={JWT}"
|
||||||
|
))));
|
||||||
|
let error = presenter
|
||||||
|
.agent_submit("随便问一句".to_string())
|
||||||
|
.expect_err("后端报错时应当返回 Err");
|
||||||
|
assert!(!error.contains("eyJhbGci"), "Err 文案里还有 JWT:{error}");
|
||||||
|
assert!(error.contains("<redacted:token>"), "{error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────── 显示闸门:另三处 scrub 站点(r2 P2-1 / K、M)───────────
|
||||||
|
|
||||||
|
/// `ConfirmRequest.tool` 也过闸门(工具名来自注册表,凭据落名字不现实,但「删了没人发现」)。
|
||||||
|
#[test]
|
||||||
|
fn confirm_tool_name_is_scrubbed() {
|
||||||
|
let mut presenter = presenter(Script::Qa);
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::ConfirmRequested {
|
||||||
|
request: ConfirmRequest {
|
||||||
|
id: "req-tool".to_string(),
|
||||||
|
tool: format!("set_config_{JWT}"),
|
||||||
|
summary: "改设置".to_string(),
|
||||||
|
details: serde_json::json!({}),
|
||||||
|
sensitivity: Sensitivity::High,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let tool = &presenter
|
||||||
|
.agent_rail()
|
||||||
|
.confirm
|
||||||
|
.as_ref()
|
||||||
|
.expect("确认请求")
|
||||||
|
.tool;
|
||||||
|
assert!(!tool.contains("eyJhbGci"), "confirm.tool 未脱敏:{tool}");
|
||||||
|
assert!(!rail_text(presenter.agent_rail()).contains("eyJhbGci"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `MessageAppended` 里带 `ToolCallView` 的那条分支(role = Tool)也要脱敏——
|
||||||
|
/// mock 只会发 user,只有真实后端会走到,所以必须单独钉住。
|
||||||
|
#[test]
|
||||||
|
fn tool_message_from_the_backend_has_its_tool_view_scrubbed() {
|
||||||
|
let mut presenter = presenter(Script::Qa);
|
||||||
|
presenter.apply(AppEvent::Agent(AgentEvent::MessageAppended(ChatMessage {
|
||||||
|
role: ChatRole::Tool,
|
||||||
|
text: format!("工具摘要:{JWT}"),
|
||||||
|
tool: Some(ToolCallView {
|
||||||
|
name: format!("read_game_log_{JWT}"),
|
||||||
|
ok: true,
|
||||||
|
summary: format!("Authorization: Bearer {JWT}"),
|
||||||
|
}),
|
||||||
|
streaming: false,
|
||||||
|
})));
|
||||||
|
|
||||||
|
let tool = presenter.agent_rail().messages[0]
|
||||||
|
.tool
|
||||||
|
.as_ref()
|
||||||
|
.expect("工具条目");
|
||||||
|
assert!(
|
||||||
|
!tool.name.contains("eyJhbGci"),
|
||||||
|
"tool.name 未脱敏:{}",
|
||||||
|
tool.name
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!tool.summary.contains("eyJhbGci"),
|
||||||
|
"tool.summary 未脱敏:{}",
|
||||||
|
tool.summary
|
||||||
|
);
|
||||||
|
assert!(!rail_text(presenter.agent_rail()).contains("eyJhbGci"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────────────────── 断言辅助 ─────────────────────────
|
||||||
|
|
||||||
/// 把 rail 上所有对用户可见的文本拼起来(红线断言用)。
|
/// 把 rail 上所有对用户可见的文本拼起来(红线断言用)。
|
||||||
|
///
|
||||||
|
/// **不含 `draft`**(见模块文档:草稿按设计可以存原文)。
|
||||||
fn rail_text(rail: &AgentRail) -> String {
|
fn rail_text(rail: &AgentRail) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for message in &rail.messages {
|
for message in &rail.messages {
|
||||||
|
|||||||
Reference in new issue
Block a user