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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user