Files
Unlucky-Launcher/crates/agent/tests/tools_security.rs
T
AskaEth 5f8382565e
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Unlucky Launcher v0.1.0
单提交快照:main 只保留已发布版本的代码树,开发史在 dev。
2026-11-01 20:02:49 +08:00

366 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 安全边界集成测试:白名单拦截、分级确认、YOLO、审计标记。
//!
//! 这些是 02 文档 §3.1「安全边界」+ §4b「YOLO」与 M12 `setting-接口约定.md` §8
//! (`Low/Medium/High` 三档 + `writable=false` 凭据)的可执行版本;
//! 判断口径对照 research/02 §7 R9(写操作的破坏半径)。
mod support;
use std::sync::Arc;
use serde_json::{json, Value};
use support::{block_on, tool_call_reply, Harness, AGENT_SESSION};
use unlucky_agent::audit::AuditOutcome;
use unlucky_agent::brain::ChatReply;
use unlucky_agent::confirm::{ConfirmDecision, ConfirmPolicy, ScriptedGate};
use unlucky_agent::event::AgentEvent;
fn set_config_reply(path: &str, value: Value) -> ChatReply {
tool_call_reply("c1", "set_config", json!({ "path": path, "value": value }))
}
fn tool_results(harness: &Harness) -> Vec<String> {
harness
.provider
.requests()
.last()
.map(|request| {
request
.messages
.iter()
.filter_map(|m| match m {
unlucky_agent::brain::ChatMessage::Tool { content, .. } => {
Some(content.clone())
}
_ => None,
})
.collect()
})
.unwrap_or_default()
}
fn confirm_events(harness: &Harness) -> usize {
harness
.events
.events()
.iter()
.filter(|e| matches!(e, AgentEvent::ConfirmRequested { .. }))
.count()
}
#[test]
fn low_key_writes_silently_without_confirmation() {
let harness = Harness::new(vec![
set_config_reply("appearance.theme_mode", json!("metro_light")),
ChatReply::text("已切换主题"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换成浅色"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1);
assert_eq!(
harness.settings.value_of("appearance.theme_mode"),
Some(json!("metro_light"))
);
assert_eq!(confirm_events(&harness), 0, "Low 键不该打扰用户");
let records = harness.audit.records_for("set_config");
assert_eq!(records.len(), 1);
assert_eq!(records[0].outcome, AuditOutcome::Ok);
assert!(!records[0].confirmed, "静默生效不是「用户确认」");
assert!(!records[0].yolo);
}
#[test]
fn medium_key_also_writes_silently() {
// M12:Medium = 静默生效但审计标记(不弹窗)
let harness = Harness::new(vec![
set_config_reply("download.region", json!("cn")),
ChatReply::text("已切到国内源"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换下载源"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1);
assert_eq!(confirm_events(&harness), 0);
assert_eq!(
harness.audit.records_for("set_config")[0].outcome,
AuditOutcome::Ok
);
}
#[test]
fn high_key_is_fail_closed_without_ui() {
let harness = Harness::new(vec![
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
ChatReply::text("需要你确认"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
)
.unwrap();
assert_eq!(
harness.settings.set_calls(),
0,
"P0 无 UI 时必须 fail-closed"
);
assert_eq!(harness.settings.value_of("java.selected_path"), None);
assert_eq!(confirm_events(&harness), 1, "确认请求必须作为事件发出");
let outcomes: Vec<AuditOutcome> = harness
.audit
.records_for("set_config")
.iter()
.map(|r| r.outcome)
.collect();
assert_eq!(
outcomes,
vec![AuditOutcome::AwaitingConfirmation, AuditOutcome::Denied],
"先记「AI 想改」,再记「被拒绝」"
);
assert!(tool_results(&harness)
.iter()
.any(|content| content.contains("denied")));
assert!(harness
.events
.events()
.iter()
.any(|e| matches!(e, AgentEvent::ToolDenied { .. })));
}
#[test]
fn user_approval_writes_and_marks_confirmed() {
let harness = Harness::with_gate(
vec![
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
ChatReply::text("已更新"),
],
ConfirmPolicy::default(),
|_sink| {
Arc::new(ScriptedGate::new(
[ConfirmDecision::Approved],
ConfirmDecision::Denied,
))
},
);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1, "确认后只写一次");
let records = harness.audit.records_for("set_config");
let last = records.last().expect("必须有审计记录");
assert_eq!(last.outcome, AuditOutcome::Ok);
assert!(last.confirmed, "用户点了允许");
assert!(!last.yolo);
}
#[test]
fn yolo_skips_confirm_but_audits_flag() {
let harness = Harness::yolo(vec![
set_config_reply("java.selected_path", json!("D:/jdk17/bin/java.exe")),
ChatReply::text("已更新"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 Java"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 1);
assert_eq!(confirm_events(&harness), 0, "YOLO 不打扰用户");
let records = harness.audit.records_for("set_config");
assert_eq!(records.len(), 1, "YOLO 只留结局记录");
assert!(records[0].yolo, "审计必须标记 yolo");
assert!(!records[0].confirmed, "自动放行不是用户确认");
assert_eq!(records[0].outcome, AuditOutcome::Ok);
}
#[test]
fn whitelist_violation_is_rejected_and_audited() {
let harness = Harness::new(vec![
set_config_reply("java.home.mystery", json!("x")),
ChatReply::text("换个方案"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "改个不存在的键"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 0);
let records = harness.audit.records_for("set_config");
assert_eq!(records.len(), 1);
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
let results = tool_results(&harness);
assert!(
results.iter().any(|c| c.contains("白名单")),
"模型需要知道为什么被拒:{results:?}"
);
}
#[test]
fn invalid_args_are_rejected_before_any_side_effect() {
// 缺 value(schema required)
let harness = Harness::new(vec![
tool_call_reply(
"c1",
"set_config",
json!({ "path": "appearance.theme_mode" }),
),
ChatReply::text("参数补上"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "改主题"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 0);
let records = harness.audit.records_for("set_config");
assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs);
assert!(tool_results(&harness)
.iter()
.any(|c| c.contains("缺少必填参数")));
// 多余参数(additionalProperties: false)
let harness = Harness::new(vec![
tool_call_reply(
"c2",
"read_game_log",
json!({ "session_id": "x", "path": "evil" }),
),
ChatReply::text("好"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "读日志"),
)
.unwrap();
let records = harness.audit.records_for("read_game_log");
assert_eq!(records[0].outcome, AuditOutcome::InvalidArgs);
}
#[test]
fn unknown_tool_lists_available_names() {
let harness = Harness::new(vec![
tool_call_reply("c1", "delete_everything", json!({})),
ChatReply::text("抱歉"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "删库跑路"),
)
.unwrap();
let records = harness.audit.records_for("delete_everything");
assert_eq!(records[0].outcome, AuditOutcome::UnknownTool);
let results = tool_results(&harness);
assert!(
results.iter().any(|c| c.contains("get_config")),
"{results:?}"
);
}
#[test]
fn locked_credential_key_is_refused_even_under_yolo() {
// M12:ai.api_key 是 writable=false(不在白名单)→ YOLO 也不能放宽可写范围
let harness = Harness::yolo(vec![
set_config_reply("ai.api_key", json!("sk-super-secret-value")),
ChatReply::text("换不了"),
]);
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "换 key"),
)
.unwrap();
assert_eq!(harness.settings.set_calls(), 0);
let records = harness.audit.records_for("set_config");
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
let args = serde_json::to_string(&records[0].args).unwrap();
assert!(
!args.contains("sk-super-secret-value"),
"审计绝不能落明文密钥:{args}"
);
assert!(args.contains("***"), "{args}");
}
#[test]
fn get_config_refuses_credential_keys() {
// F1:模型可自由给 path,凭据键必须读不到 —— 否则值会进模型上下文发往远端 LLM。
let harness = Harness::new(vec![
tool_call_reply("c1", "get_config", json!({ "path": "ai.api_key" })),
ChatReply::text("读不到"),
]);
// 模拟 M12 真实实现:凭据键在文件里确实有值(writable=false,不在白名单也要能防住读)
harness
.settings
.seed_value("ai.api_key", json!("sk-live-credential-value"));
let mut history = Vec::new();
block_on(
harness
.brain
.run_turn(AGENT_SESSION, &mut history, "看看我的 key"),
)
.unwrap();
// 1) 工具报错并被审计
let records = harness.audit.records_for("get_config");
assert_eq!(records[0].outcome, AuditOutcome::ToolError);
// 2) 模型拿到的是拒绝文案,不是值
let results = tool_results(&harness);
assert!(
results.iter().any(|c| c.contains("凭据")),
"应给出可读拒绝:{results:?}"
);
assert!(
!results
.iter()
.any(|c| c.contains("sk-live-credential-value")),
"工具结果绝不能带凭据:{results:?}"
);
// 3) 事件流与对话历史里也不能出现(P1 事件会落盘 / 历史会持久化)
let leaked_in_events = harness
.events
.events()
.iter()
.any(|e| format!("{e:?}").contains("sk-live-credential-value"));
assert!(!leaked_in_events, "事件流不能带凭据明文");
let leaked_in_history = history
.iter()
.any(|m| format!("{m:?}").contains("sk-live-credential-value"));
assert!(!leaked_in_history, "对话历史不能带凭据明文");
}