Unlucky Launcher v0.0.1-alpha
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+365
View File
@@ -0,0 +1,365 @@
//! 安全边界集成测试:白名单拦截、分级确认、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, "对话历史不能带凭据明文");
}