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
312 lines
12 KiB
Rust
312 lines
12 KiB
Rust
//! AI 助手(D16 双形态)的 presenter 门面:视图模型 + 提交/确认 + 裁决审计。
|
||
//!
|
||
//! # 1. 这一层为什么单独存在
|
||
//!
|
||
//! UI 壳(右侧常驻栏 = AGENTRAIL)只做两件事:读 [`AgentRail`] 画、把点击翻成这五个方法。
|
||
//! 会话怎么攒、未读怎么算、YOLO 怎么裁决、审计记什么,全在这里与
|
||
//! [`crate::state::AppState::apply_agent`],壳里不重复实现——否则两形态(栏 / 页)会各写一套
|
||
//! 而漂移。
|
||
//!
|
||
//! ```text
|
||
//! UI 壳 ──agent_submit/agent_confirm/agent_mark_read──► Presenter ──AgentTransport──► 后端
|
||
//! ▲ │
|
||
//! └────────── agent_rail()(只读视图模型)◄──────────────┘ events → AppState::apply
|
||
//! ```
|
||
//!
|
||
//! # 2. 与 UI 壳的契约(签名已定,改名要先报 tower)
|
||
//!
|
||
//! ```text
|
||
//! Presenter::agent_rail(&self) -> &AgentRail
|
||
//! Presenter::agent_submit(&mut self, text: String) -> Result<(), String>
|
||
//! Presenter::agent_mark_read(&mut self)
|
||
//! Presenter::agent_confirm(&mut self, accept: bool)
|
||
//! ```
|
||
//!
|
||
//! # 3. 铁律落点(AGENTS §6)
|
||
//!
|
||
//! - **AI 永不接触账户凭据**:事件里没有凭据字段;文本一律过 [`rail::scrub_text`](第二道防线)。
|
||
//! - **写配置受 schema 白名单约束**:白名单在 agent 侧工具层,presenter 只负责「把确认问出来」。
|
||
//! - **敏感键分级确认,YOLO 只跳确认且审计照记**:[`Presenter::agent_confirm`] 在 YOLO 下
|
||
//! 一律放行,但**照样**记一条 [`AuditEntry`](`yolo = true, confirmed = false`)——不静默跳过。
|
||
//!
|
||
//! # 4. 没做的事(P1 再接)
|
||
//!
|
||
//! - 真实后端:本模块只认 [`AgentTransport`] 端口,`rig` 那侧由 host 装配时实现;
|
||
//! - 落盘审计:agent 侧 `brain::exec` → `audit` 才是权威记录,这里的 [`AuditEntry`] 只是
|
||
//! 本进程内、给 UI 看的裁决镜像(`presenter.audit()`);
|
||
//! - 流式时序:端口是同步返回事件分片的(便于单测),真实实现是异步推流。
|
||
|
||
use crate::dto::{AppEvent, Sensitivity};
|
||
use crate::prefs::UiPrefs;
|
||
use crate::router::Route;
|
||
use crate::state::AppState;
|
||
|
||
pub mod mock;
|
||
pub mod rail;
|
||
|
||
use rail::scrub_text;
|
||
pub use rail::AgentRail;
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|
||
#[cfg(test)]
|
||
#[path = "agent/tests_red_line.rs"]
|
||
mod tests_red_line;
|
||
|
||
/// 裁决记录上限(只留最近若干条;权威审计不在这里)。
|
||
pub const MAX_AUDIT_ENTRIES: usize = 256;
|
||
|
||
/// 一次确认的结局。
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum ConfirmVerdict {
|
||
/// 放行(用户点了允许,或 YOLO 自动放行)
|
||
Approved,
|
||
/// 拒绝(工具调用会带「用户拒绝」回到模型)
|
||
Denied,
|
||
}
|
||
|
||
impl ConfirmVerdict {
|
||
/// 是否放行。
|
||
#[must_use]
|
||
pub fn is_approved(self) -> bool {
|
||
matches!(self, Self::Approved)
|
||
}
|
||
}
|
||
|
||
/// 一条确认裁决的审计记录。
|
||
///
|
||
/// `yolo` 与 `confirmed` 必须分开记:`yolo = true, confirmed = false`(自动放行)与
|
||
/// `yolo = false, confirmed = true`(用户亲手点的允许)在事故复盘里是完全不同的两件事。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct AuditEntry {
|
||
/// 请求 id(与 `AgentEvent::ConfirmRequested` 同一个)
|
||
pub request_id: String,
|
||
/// 工具名
|
||
pub tool: String,
|
||
/// 敏感等级
|
||
pub sensitivity: Sensitivity,
|
||
/// 结局
|
||
pub verdict: ConfirmVerdict,
|
||
/// 是否运行在 YOLO 模式
|
||
pub yolo: bool,
|
||
/// 是否经过用户确认(YOLO 下恒为 false)
|
||
pub confirmed: bool,
|
||
}
|
||
|
||
/// agent 后端端口:presenter 不认 `rig` / 异步运行时的具体类型(依赖方向见 crate 文档)。
|
||
///
|
||
/// 方法同步返回事件分片是刻意的:真实后端在后台线程里跑,事件经
|
||
/// [`crate::dto::Backend::drain_events`] 回流;端口这层只表达「一轮输入会产生哪些事件」,
|
||
/// 这样单测可以不碰定时器与网络。
|
||
pub trait AgentTransport {
|
||
/// 用户提交一句话。
|
||
///
|
||
/// `history` 是当前 rail 的只读快照(真实后端要靠它拼上下文,mock 靠它算消息下标)。
|
||
/// 返回的事件分片由 [`Presenter`] 顺序 `apply`;`Err` 表示这一轮根本没起来(不产生事件)。
|
||
fn submit(&mut self, text: &str, history: &AgentRail) -> Result<Vec<AppEvent>, String>;
|
||
|
||
/// 用户对写操作做了决定(`accept = false` 表示拒绝)。
|
||
fn confirm(&mut self, request_id: &str, accept: bool) -> Vec<AppEvent>;
|
||
}
|
||
|
||
/// AI 助手的 presenter 门面:持有一份 [`AppState`] 与一个后端端口。
|
||
///
|
||
/// 事件仍走 [`AppState::apply`](唯一状态变更入口)——[`Presenter::apply`] 只是转发,
|
||
/// 不在这一层另做投影。
|
||
pub struct Presenter {
|
||
state: AppState,
|
||
transport: Box<dyn AgentTransport>,
|
||
audit: Vec<AuditEntry>,
|
||
}
|
||
|
||
impl std::fmt::Debug for Presenter {
|
||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
formatter
|
||
.debug_struct("Presenter")
|
||
.field("rail", self.agent_rail())
|
||
.field("audit_len", &self.audit.len())
|
||
.finish_non_exhaustive()
|
||
}
|
||
}
|
||
|
||
impl Presenter {
|
||
/// 空状态 + 指定后端端口。
|
||
#[must_use]
|
||
pub fn new(transport: Box<dyn AgentTransport>) -> Self {
|
||
Self::with_state(AppState::new(), transport)
|
||
}
|
||
|
||
/// 接管一份已有状态(UI 壳先建 `AppState` 再交给 presenter 时用)。
|
||
#[must_use]
|
||
pub fn with_state(state: AppState, transport: Box<dyn AgentTransport>) -> Self {
|
||
Self {
|
||
state,
|
||
transport,
|
||
audit: Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// 只读状态(UI 画其它页面用)。
|
||
#[must_use]
|
||
pub fn state(&self) -> &AppState {
|
||
&self.state
|
||
}
|
||
|
||
/// 可写状态——**只在测试构建里存在**。
|
||
///
|
||
/// 非测试代码没有这个后门:`AppState::ai` 的字段全是 pub,返回 `&mut AppState` 就等于
|
||
/// 让调用方绕开 [`AgentRail::ingest`] 的脱敏与 `busy` 不变量。需要写状态时走下面这些
|
||
/// 窄入口(prefs / 导航 / rail 的那几个字段),rail 的内容一律走四个契约方法。
|
||
#[cfg(test)]
|
||
pub fn state_mut(&mut self) -> &mut AppState {
|
||
&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;
|
||
}
|
||
|
||
/// 输入框草稿(用户打字时同步进来,切形态不丢)。
|
||
///
|
||
/// 草稿**是** rail 上唯一能合法躺着凭据原文的字段:它是「还没发出去的字」,写进来时不过
|
||
/// 脱敏(UI 要能原样显示用户正在打的字)。安全性靠另一条:`agent_submit` 脱敏的是**入参**
|
||
/// 而不是草稿,所以原文既不会进会话历史,也不会被发给后端。
|
||
pub fn agent_set_draft(&mut self, text: String) {
|
||
self.state.ai.draft = text;
|
||
}
|
||
|
||
/// 喂一条事件(纯转发给 [`AppState::apply`])。
|
||
pub fn apply(&mut self, event: AppEvent) {
|
||
self.state.apply(event);
|
||
}
|
||
|
||
/// 当前会话的视图模型(唯一读入口;两形态共享同一份)。
|
||
#[must_use]
|
||
pub fn agent_rail(&self) -> &AgentRail {
|
||
&self.state.ai
|
||
}
|
||
|
||
/// 提交一句话。
|
||
///
|
||
/// 拒绝的三种情形都不改状态(草稿保留,用户能接着改):
|
||
/// 空文本、上一轮还在跑、有写操作待确认。
|
||
///
|
||
/// 成功时清空草稿、立刻把 `busy` 置真(**然后**才投影后端返回的事件分片),最后按消息
|
||
/// 重算一次 `streaming`——异步后端在「提交成功 → 第一个事件到达」之间的空窗因此不会
|
||
/// 让用户再点一次发送(那会产生两条 user 消息、两轮回复)。
|
||
/// 用户气泡**由事件产生**(不在本地乐观回显,见 `lib.rs` §3)。
|
||
pub fn agent_submit(&mut self, text: String) -> Result<(), String> {
|
||
let trimmed = text.trim();
|
||
if trimmed.is_empty() {
|
||
return Err("请输入内容".to_string());
|
||
}
|
||
if self.state.ai.confirm.is_some() {
|
||
return Err("有写操作待确认:先允许或拒绝,再继续对话".to_string());
|
||
}
|
||
if self.state.ai.busy {
|
||
return Err("上一轮回复还没结束".to_string());
|
||
}
|
||
// 用户输入也过脱敏:他可能顺手把 token 粘进对话框,而这段文本会进会话历史、
|
||
// 会作为 prompt **发到模型服务商**、也会进导出。这道是**出站**闸门(rail 侧那道是
|
||
// 显示闸门,两者不能互相替代:删掉这道,界面照样干净,但凭据已经出去了)。
|
||
// 守卫见 agent/tests_red_line.rs::outbound_prompt_is_scrubbed_before_it_reaches_the_backend。
|
||
let text = scrub_text(trimmed);
|
||
let events = self
|
||
.transport
|
||
.submit(&text, &self.state.ai)
|
||
// 失败原因同样可能回显 key(provider 的 401 常把 key 抄进 message),
|
||
// 这条 Err 会直接显示在 UI 上,守卫见 outbound_error_text_is_scrubbed。
|
||
.map_err(|error| scrub_text(&error))?;
|
||
self.state.ai.draft.clear();
|
||
self.state.ai.error = None;
|
||
self.state.ai.begin_turn();
|
||
for event in events {
|
||
self.state.apply(event);
|
||
}
|
||
self.state.ai.settle_streaming();
|
||
Ok(())
|
||
}
|
||
|
||
/// 未读清零(UI 在「用户打开栏 / 停在独立页」时调用)。
|
||
pub fn agent_mark_read(&mut self) {
|
||
self.state.ai.mark_read();
|
||
}
|
||
|
||
/// 对当前待确认的写操作做决定。
|
||
///
|
||
/// - 没有待确认:no-op(不记审计,不 panic);
|
||
/// - YOLO 下:`accept` 被忽略,一律放行,审计记 `yolo = true, confirmed = false`;
|
||
/// - 非 YOLO:按 `accept` 裁决,审计记 `confirmed = true`。
|
||
///
|
||
/// 两种情形都走同一条事件回流(后端返回的工具收尾事件),所以会话里看到的工具条目
|
||
/// 与审计记录不会各行其是。
|
||
///
|
||
/// # UI 壳的义务
|
||
///
|
||
/// 看到 `rail.confirm.is_some()` 就要给出决定:非 YOLO 弹确认框,YOLO 直接调
|
||
/// `agent_confirm(true)`(不弹窗,但**必须**调——否则 `busy` 一直锁着输入)。
|
||
/// 真实后端在 YOLO 下不会发 `ConfirmRequested`(agent 侧 ConfirmGate 已自动放行),
|
||
/// 这条路径是防御性的;另一条收敛途径是后端发 `AgentEvent::ConfirmResolved`。
|
||
pub fn agent_confirm(&mut self, accept: bool) {
|
||
let Some(request) = self.state.ai.take_confirm() else {
|
||
return;
|
||
};
|
||
let yolo = self.state.ai.yolo;
|
||
let verdict = if yolo || accept {
|
||
ConfirmVerdict::Approved
|
||
} else {
|
||
ConfirmVerdict::Denied
|
||
};
|
||
self.record(AuditEntry {
|
||
request_id: request.id.clone(),
|
||
tool: request.tool.clone(),
|
||
sensitivity: request.sensitivity,
|
||
verdict,
|
||
yolo,
|
||
confirmed: !yolo,
|
||
});
|
||
let events = self.transport.confirm(&request.id, verdict.is_approved());
|
||
for event in events {
|
||
self.state.apply(event);
|
||
}
|
||
}
|
||
|
||
/// 本进程内的裁决记录(最旧在前;权威审计在 agent 侧)。
|
||
#[must_use]
|
||
pub fn audit(&self) -> &[AuditEntry] {
|
||
&self.audit
|
||
}
|
||
|
||
/// 记一条裁决,超出 [`MAX_AUDIT_ENTRIES`] 时丢最旧的。
|
||
fn record(&mut self, entry: AuditEntry) {
|
||
if self.audit.len() == MAX_AUDIT_ENTRIES {
|
||
self.audit.remove(0);
|
||
}
|
||
self.audit.push(entry);
|
||
}
|
||
}
|