From 38c5584ec33f55f59db3941106511ce5b0983474 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:55:22 +0800 Subject: [PATCH 1/3] Security page: guard states, grouped rules with bulk toggles, answer-text content rules - Tabs renamed to Secrets & privacy / Dangerous commands / Content filter, each with a status dot; every guard has the same three states On / Monitor / Off, with a one-line state sentence and the enabled-rule count. - Rule lists: built-ins grouped by category with a group checkbox (indeterminate when partly on) that turns the group on or off in one write, a custom group, a search box over names and patterns, per-row switch and inline action select, and "Monitoring" next to the action while the guard is in Monitor. - Content rule dialog: Checks (request / answer / both), match with a case switch on the same row, actions Refuse / Delete / Replace / Record only with disabled options, reasons and automatic switches when answers are checked, replacement text prefilled with ***, and testing as request or as answer. - Redaction rules get an action (replace with placeholder / record only) and a case switch instead of a toggle button. - Log and hit detail show answer-text hits and replaced / deleted segment counts. - Notifications: record-only and Monitor hits of all three guards notify with the rule and upstream only, at most once per rule in five minutes, and hits that arrive together are merged into one notification; the cut notification stays. - Protocol 48 types and the bulk endpoint call live in src/security/contract-types.ts until core lands and the generated types carry them. Co-Authored-By: Claude Opus 5.5 --- src-tauri/src/core_text.rs | 5 + src-tauri/src/notices/mod.rs | 204 +++++- src-tauri/src/notices/rules.rs | 156 ++++- src-tauri/src/notices/tests.rs | 253 +++++++- src/guide/guide.i18n.ts | 8 +- src/i18n/core.zh.json | 56 +- src/i18n/terminology.md | 13 +- src/labels.i18n.ts | 36 +- src/overview/overview.i18n.ts | 4 +- src/security/GuardTab.i18n.tsx | 151 ++--- src/security/GuardTab.tsx | 606 ++++++++++-------- .../{HitDetail.i18n.ts => HitDetail.i18n.tsx} | 43 +- src/security/HitDetail.tsx | 17 +- src/security/LogTab.tsx | 3 +- src/security/RuleDialog.i18n.ts | 179 +++--- src/security/RuleDialog.tsx | 561 ++++++++++------ src/security/SecurityPage.i18n.tsx | 33 +- src/security/SecurityPage.tsx | 161 +++-- src/security/api.ts | 72 ++- src/security/contract-types.ts | 121 ++++ src/security/export.i18n.ts | 3 + src/security/export.test.ts | 4 +- src/security/export.ts | 15 +- src/security/labels.i18n.tsx | 48 +- src/security/labels.test.ts | 21 +- src/security/labels.tsx | 24 +- src/security/rules.test.ts | 137 ++++ src/security/rules.ts | 122 ++++ src/security/useSecurityLog.ts | 2 +- src/security/useTrial.ts | 8 +- src/traffic/drawer/RequestDrawer.i18n.tsx | 4 +- src/ui/checkbox.tsx | 7 +- 32 files changed, 2182 insertions(+), 895 deletions(-) rename src/security/{HitDetail.i18n.ts => HitDetail.i18n.tsx} (67%) create mode 100644 src/security/contract-types.ts create mode 100644 src/security/rules.test.ts create mode 100644 src/security/rules.ts diff --git a/src-tauri/src/core_text.rs b/src-tauri/src/core_text.rs index 40c5c127..79f6aa98 100644 --- a/src-tauri/src/core_text.rs +++ b/src-tauri/src/core_text.rs @@ -44,6 +44,11 @@ pub fn text(m: &Msg) -> String { tr!(zh(m).unwrap_or_else(|| m.text.clone()), m.text.clone()) } +/// 词表里的一项(`guard`、`secret_rule`、`content_rule`…),中文。表或项不在是 `None` +pub fn table_zh(table: &str, key: &str) -> Option { + self::table().tables.get(table)?.get(key).cloned() +} + /// 同 [`text`],去掉句末的句号:后面还要接自己的一句时用 pub fn clause(m: &Msg) -> String { let s = text(m); diff --git a/src-tauri/src/notices/mod.rs b/src-tauri/src/notices/mod.rs index 93d9def1..a9f3f3c7 100644 --- a/src-tauri/src/notices/mod.rs +++ b/src-tauri/src/notices/mod.rs @@ -82,6 +82,13 @@ const COOLDOWN: Duration = Duration::from_secs(300); /// 通知中心里原地贴一次。10 秒里合成一次,列表上的次数晚几秒跟上,没有别的代价 const GATHER: Duration = Duration::from_secs(10); +/// 同时命中的几条安全规则([`Signal::merged`]):要弹的先等这么久,这段时间里要弹的合成一条。 +/// +/// **2 秒**:一个请求里几条规则一起命中时,core 是一条接一条发事件的(同一个请求的事件前后 +/// 相差几毫秒),流式回答里的命中也在同一秒里到;再长,第一条通知就晚得让人觉得和那次请求 +/// 对不上了 +const MERGE: Duration = Duration::from_secs(2); + #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, )] @@ -146,6 +153,8 @@ pub struct Signal { pub times: u32, /// 级别只是 info,也弹系统通知(见 [`Signal::tell`]) pub tell: bool, + /// 同时要弹的几条合成一条通知时,这一条写成的那一行(见 [`Signal::merged`]) + pub merge: Option, } impl Signal { @@ -163,6 +172,7 @@ impl Signal { gather: false, times: 1, tell: false, + merge: None, } } @@ -180,6 +190,7 @@ impl Signal { gather: false, times: 1, tell: false, + merge: None, } } @@ -245,6 +256,16 @@ impl Signal { self } + /// 同时要弹的几件事合成一条通知(安全规则「仅记录」的命中):要弹的时候先等 [`MERGE`], + /// 这段时间里别的合成通知也要弹的,一起说成一条,`line` 是这一件在那一条里的样子。 + /// + /// **只合成要弹的那一下**:列表里还是各是各的一条,去重、冷却照旧按各自的键。合成的那一条 + /// 只取一个令牌 + pub fn merged(mut self, line: impl Into) -> Self { + self.merge = Some(line.into()); + self + } + /// 这一条算几次 pub fn times(mut self, n: u32) -> Self { self.times = n.max(1); @@ -298,6 +319,8 @@ struct Open { cool_until: Option, /// 冷却期间又发生、还没为它弹过的次数。冷却结束时合成一条说 unsaid: u32, + /// 要弹时和同时要弹的合成一条([`Signal::merged`]),这是它那一行 + merge: Option, } #[derive(Debug, Default)] @@ -327,6 +350,8 @@ pub struct Notices { /// 正在攒的那几件事,按键([`Signal::gathered`])。**和 `state` 分开一把锁**:攒只是 /// 记一笔,不碰列表 gathering: Mutex>, + /// 等着合成一条再弹的那几件事的键,按先后([`Signal::merged`]) + merging: Mutex>, } /// 一个键这一阵攒下的 @@ -362,6 +387,7 @@ impl Notices { intermittent: false, cool_until: None, unsaid: 0, + merge: None, }, ) }) @@ -373,6 +399,7 @@ impl Notices { mode: Mutex::new(mode), store, gathering: Mutex::new(HashMap::new()), + merging: Mutex::new(Vec::new()), }) } @@ -684,7 +711,10 @@ impl Notices { let interrupts = mode == Mode::System && (signal.level.interrupts() || signal.tell); let wants = interrupts && !quiet; let due = (wants && signal.hold).then(|| now + HOLD); - let deliver = wants && !signal.hold && take_token(&mut g, &signal.key, signal.level); + // 合成的那几条到点一起只取一个令牌(`flush_merged`) + let merged = signal.merge.is_some(); + let deliver = + wants && !signal.hold && (merged || take_token(&mut g, &signal.key, signal.level)); let mut sum_up = None; if signal.event { if deliver { @@ -707,6 +737,7 @@ impl Notices { intermittent: false, cool_until, unsaid, + merge: signal.merge.clone(), }, ); (notice, deliver, due, sum_up) @@ -788,14 +819,14 @@ impl Notices { .open .get(&key) .filter(|o| o.cool_until == Some(at) && o.unsaid > 0) - .map(|o| (o.notice.clone(), o.unsaid, o.muted_until)); - let Some((notice, unsaid, muted)) = pending else { + .map(|o| (o.notice.clone(), o.unsaid, o.muted_until, o.merge.is_some())); + let Some((notice, unsaid, muted, merged)) = pending else { return; }; let say = wants && !notice.read && !hushed(&g, &key, muted, Instant::now()) - && take_token(&mut g, &key, notice.level); + && (merged || take_token(&mut g, &key, notice.level)); if let Some(o) = g.open.get_mut(&key) { o.unsaid = 0; if say { @@ -815,7 +846,11 @@ impl Notices { }); } - fn deliver(&self, notice: &Notice) { + fn deliver(self: &Arc, notice: &Notice) { + if self.merges(¬ice.key) { + self.merge_later(¬ice.key); + return; + } let held = { let mut g = self.state.lock().expect("锁未中毒"); if let Some(o) = g.open.get_mut(¬ice.key) { @@ -828,20 +863,109 @@ impl Notices { }; let mut shown = notice.clone(); shown.notified = true; - if held > 0 { - // 被限流压下去的那些,在这一条里带一句 - shown.body = tr!( - format!("{}(另有 {held} 项待处理)", shown.body), - if held == 1 { - format!("{} (1 more notice pending)", shown.body) - } else { - format!("{} ({held} more notices pending)", shown.body) - } - ); + shown.body = with_held(shown.body, held); + for s in &self.sinks { + s.show(&shown); + } + } + + /// 这一条要和同时要弹的合成一条([`Signal::merged`]) + fn merges(&self, key: &str) -> bool { + self.state + .lock() + .expect("锁未中毒") + .open + .get(key) + .is_some_and(|o| o.merge.is_some()) + } + + /// 排进等着合成的那一批。这一批的头一条排上到点合成的那个时刻 + fn merge_later(self: &Arc, key: &str) { + let first = { + let mut m = self.merging.lock().expect("锁未中毒"); + if m.iter().any(|k| k == key) { + return; + } + m.push(key.to_string()); + m.len() == 1 + }; + if !first { + return; } + // 时刻在这里定,不等任务头一次跑起来(和攒、冷却的计时器一样) + let at = tokio::time::Instant::now() + MERGE; + let me = self.clone(); + tokio::spawn(async move { + tokio::time::sleep_until(at).await; + me.flush_merged(); + }); + } + + /// 到点:这一批里还开着、用户还没看过的,合成一条弹出去。**只有一条就照它自己的样子说**; + /// 几条就说「N 条安全规则命中」,正文逐条写规则和上游。 + /// + /// 合成的那一条用第一条的键:点它落到第一条的页面、标它已读时撤掉的也是它。只有这个键算 + /// 弹过(`notified`),别的几条之后原地更新时不会在通知中心里各冒出一条 + fn flush_merged(&self) { + let keys = std::mem::take(&mut *self.merging.lock().expect("锁未中毒")); + if self.mode() != Mode::System { + return; + } + let shown = { + let mut g = self.state.lock().expect("锁未中毒"); + let batch: Vec<(Notice, String)> = keys + .iter() + .filter_map(|k| g.open.get(k)) + .filter(|o| !o.notice.read) + .map(|o| (o.notice.clone(), o.merge.clone().unwrap_or_default())) + .collect(); + let Some((first, _)) = batch.first() else { + return; + }; + let level = batch + .iter() + .map(|(n, _)| n.level) + .max() + .unwrap_or(first.level); + if !take_token(&mut g, MERGED_KEY, level) { + // 限流压下去的按各自的键记着,下一条弹出去的通知里带一句「另有 N 项」 + g.held_back.remove(MERGED_KEY); + for (n, _) in &batch { + g.held_back.insert(n.key.clone()); + } + return; + } + let first = first.clone(); + if let Some(o) = g.open.get_mut(&first.key) { + o.notice.notified = true; + } + let mut held = std::mem::take(&mut g.held_back); + for (n, _) in &batch { + held.remove(&n.key); + } + let mut shown = if batch.len() == 1 { + first.clone() + } else { + Notice { + level, + title: tr!( + format!("{} 条安全规则命中", batch.len()), + format!("{} Security Rules Matched", batch.len()) + ), + body: merged_body(batch.iter().map(|(_, line)| line.as_str()).collect()), + count: 1, + ..first.clone() + } + }; + shown.notified = true; + shown.body = with_held(shown.body, held.len()); + shown + }; for s in &self.sinks { s.show(&shown); } + self.persist(); + self.changed(); } fn clear(&self, key: &str, at_ms: u64) { @@ -889,6 +1013,7 @@ impl Notices { intermittent: true, cool_until: None, unsaid: 0, + merge: None, }, ); } @@ -946,6 +1071,55 @@ impl Notices { } } +/// 合成的那一条取令牌时用的键(被限流时不记它,记那一批各自的键) +const MERGED_KEY: &str = "secwatch"; + +/// 一条合成通知里最多逐条写几件,其余写「等 N 条」 +const MERGED_LINES: usize = 4; + +/// 合成那一条的正文:逐条写规则和上游,多了截住 +fn merged_body(lines: Vec<&str>) -> String { + let n = lines.len(); + let shown = &lines[..n.min(MERGED_LINES)]; + let rest = n - shown.len(); + tr!( + { + let more = if rest > 0 { + format!("等 {n} 条") + } else { + String::new() + }; + format!("{}{more}。均已记入安全日志。", shown.join(";")) + }, + { + let more = if rest > 0 { + format!("; and {rest} more") + } else { + String::new() + }; + format!( + "{}{more}. All were recorded in the security log.", + shown.join("; ") + ) + } + ) +} + +/// 被限流压下去的那些,在弹出去的这一条里带一句 +fn with_held(body: String, held: usize) -> String { + if held == 0 { + return body; + } + tr!( + format!("{body}(另有 {held} 项待处理)"), + if held == 1 { + format!("{body} (1 more notice pending)") + } else { + format!("{body} ({held} more notices pending)") + } + ) +} + /// 开着的全部,最近的在前 fn newest_first(state: &State) -> Vec { let mut out: Vec = state.open.values().map(|o| o.notice.clone()).collect(); diff --git a/src-tauri/src/notices/rules.rs b/src-tauri/src/notices/rules.rs index b7165cb1..d74b9e2f 100644 --- a/src-tauri/src/notices/rules.rs +++ b/src-tauri/src/notices/rules.rs @@ -57,7 +57,7 @@ const LISTEN_SETTINGS: &str = "settings:listen"; pub fn default_view(key: &str) -> &'static str { match key.split(':').next().unwrap_or(key) { "upstream" | "quota" | "credential" | "auth" | "writeback" | "proxy" => UPSTREAMS, - "toolwall" => SECURITY, + "toolwall" | "secwatch" => SECURITY, // 客户端配置里的可疑内容在 MCP 页:服务器、技能、钩子和扫描发现都在那儿 "scan" => MCP, "plugin" => PLUGINS, @@ -80,6 +80,7 @@ pub fn suppresses(key: &str) -> &'static [&'static str] { "auth:", "proxy:", "toolwall:", + "secwatch:", "writeback:", ], _ => &[], @@ -352,8 +353,8 @@ pub fn from_event(ev: &Event) -> Vec { .now(), ] } - // 客户端此刻正在弹批准提示,**这一条要立刻说**。只说处置为「切断」的 - // 那些规则:「仅记录」的那一类本来就是用户说了不必打断的。 + // 客户端此刻正在弹批准提示,**这一条要立刻说**。只说真的切断了的:防护处于「监控」时 + // 命中「切断」规则、或者规则本来就是「仅记录」的,走下面「只记录」那一类。 // // **每拦下一次都是一件新的事**(`event`):用户看过上一次之后再拦下,照样要说; // 同一家接连拦下的,由冷却合成一条,不刷屏 @@ -362,34 +363,18 @@ pub fn from_event(ev: &Event) -> Vec { tool, rule, action, - blocked, + blocked: true, .. } if action == "cut" => { - let title = if *blocked { - tr!( - format!("已拦截 {provider} 返回的 {tool} 调用"), - format!("Blocked {tool} Call from “{provider}”") - ) - } else { - tr!( - format!("{provider} 返回了可疑的 {tool} 调用"), - format!("Suspicious {tool} Call from “{provider}”") - ) - }; + let title = tr!( + format!("已拦截 {provider} 返回的 {tool} 调用"), + format!("Blocked {tool} Call from “{provider}”") + ); let name = tool_rule_name(rule); - let body = if *blocked { - tr!( - format!("命中规则「{name}」,响应已切断。"), - format!("It matched the rule “{name}”, so the response was cut off.") - ) - } else { - tr!( - format!("命中规则「{name}」。建议在客户端拒绝此调用。"), - format!( - "It matched the rule “{name}”. Rejecting this call in the client is recommended." - ) - ) - }; + let body = tr!( + format!("命中规则「{name}」,响应已切断。"), + format!("It matched the rule “{name}”, so the response was cut off.") + ); // **正文不带调用内容**:系统通知在锁屏上也看得见 vec![ Signal::raised(format!("toolwall:{provider}"), Level::Warning, title) @@ -398,6 +383,31 @@ pub fn from_event(ev: &Event) -> Vec { .event(), ] } + Event::ToolCallFlagged { + provider, + rule, + custom, + detail, + .. + } => watched("inspect_tools", provider, rule, *custom, detail) + .into_iter() + .collect(), + Event::ContentMatched { + provider, + rule, + custom, + detail, + .. + } => watched("content", provider, rule, *custom, detail) + .into_iter() + .collect(), + // 一次请求里找到的每个值各一项;同一条规则找到几个值,就是同一条规则命中了几次 + Event::SecretsFound { + provider, items, .. + } => items + .iter() + .filter_map(|i| watched("redact", provider, &i.rule, i.custom, &i.detail)) + .collect(), Event::KeyLimitAlert { .. } => key_limit(ev), Event::PluginFailed { plugin_id, @@ -788,6 +798,96 @@ pub fn from_snapshot(s: &Snapshot<'_>, now_ms: u64) -> Vec { .collect() } +/// 「仅记录」的命中:规则的动作就是仅记录,或者防护处于「监控」(那时每条规则都按仅记录 +/// 处理)。**只认 core 说的结局**(`outcome_detail` 是 `recorded`),不自己按档位和动作推。 +/// +/// 规格(安全页重整 §3):仅记录 = 记入安全日志并发出系统通知。正文**只有规则名和上游**, +/// 不带命中的原文(锁屏上也看得见)。键按规则分(`secwatch:<防护>::<规则>`),所以 +/// **同一条规则 5 分钟里至多弹一次**([`Signal::event`] 的冷却);同时命中的几条规则由总线 +/// 合成一条通知([`Signal::merged`],那一行是这条规则在合成通知里的样子)。 +fn watched( + guard: &'static str, + provider: &str, + rule: &str, + custom: bool, + detail: &tw_api::SecurityHitDetail, +) -> Option { + if !matches!(detail.outcome_detail, tw_api::OutcomeDetail::Recorded {}) { + return None; + } + let name = guard_rule_name(guard, rule, custom, &detail.rule_snapshot.name); + let what = guard_name(guard); + let answer = detail.direction == tw_api::SecurityDirection::Response; + let body = match (answer, crate::i18n::current()) { + (false, crate::i18n::Lang::Zh) => { + format!("{what}:经上游「{provider}」的请求命中此规则,已记入安全日志。") + } + (true, crate::i18n::Lang::Zh) => { + format!("{what}:上游「{provider}」的回答命中此规则,已记入安全日志。") + } + (false, crate::i18n::Lang::En) => format!( + "{what}: a request through upstream “{provider}” matched this rule. It was recorded in the security log." + ), + (true, crate::i18n::Lang::En) => format!( + "{what}: an answer from upstream “{provider}” matched this rule. It was recorded in the security log." + ), + }; + let kind = if custom { "c" } else { "b" }; + Some( + Signal::raised( + format!("secwatch:{guard}:{kind}:{rule}"), + Level::Warning, + tr!( + format!("安全规则「{name}」命中"), + format!("Security Rule “{name}” Matched") + ), + ) + .body(body) + .view(SECURITY) + .event() + .merged(tr!( + format!("「{name}」· {provider}"), + format!("“{name}” · {provider}") + )), + ) +} + +/// 防护的名字,和安全页的页签一样 +fn guard_name(guard: &str) -> String { + tr!( + crate::core_text::table_zh("guard", guard).unwrap_or_else(|| guard.to_string()), + match guard { + "redact" => "Secrets & privacy", + "inspect_tools" => "Dangerous commands", + _ => "Content filter", + } + .to_string() + ) +} + +/// 规则的名字,和安全页上的一样:自定义规则就是用户起的名字;内置的中文查词表(和界面同一张), +/// 查不到、或者英文界面,用 core 给的英文名 +fn guard_rule_name(guard: &str, rule: &str, custom: bool, english: &str) -> String { + if custom { + return rule.to_string(); + } + let english = if english.is_empty() { rule } else { english }; + match guard { + "inspect_tools" => tool_rule_name(rule), + _ => { + let table = if guard == "redact" { + "secret_rule" + } else { + "content_rule" + }; + tr!( + crate::core_text::table_zh(table, rule).unwrap_or_else(|| english.to_string()), + english.to_string() + ) + } + } +} + /// 工具调用规则的名字,和安全页上的一样。**只有内置的这些**:自定义规则的 /// id 就是用户起的名字,原样用。 fn tool_rule_name(rule: &str) -> String { diff --git a/src-tauri/src/notices/tests.rs b/src-tauri/src/notices/tests.rs index 3372ee87..8a38ccac 100644 --- a/src-tauri/src/notices/tests.rs +++ b/src-tauri/src/notices/tests.rs @@ -849,23 +849,258 @@ async fn each_plugin_is_gathered_on_its_own() { assert_eq!(b.titles().len(), 2, "{:?}", b.titles()); } -#[test] -fn a_rule_that_only_records_does_not_interrupt_anyone() { - // 「仅记录」的那一类是用户说了不必打断的 - let signals = rules::from_event(&tw_api::Event::ToolCallFlagged { +// ---------------------------------------------------------------- 仅记录与监控(安全页重整) + +/// 一次命中的细节。`recorded`:core 说的结局是只记录(规则是「仅记录」,或者防护处于「监控」) +fn hit_detail( + direction: tw_api::SecurityDirection, + id: &str, + name: &str, + recorded: bool, +) -> tw_api::SecurityHitDetail { + tw_api::SecurityHitDetail { + direction, + locations: Vec::new(), + more_locations: 0, + rule_snapshot: tw_api::RuleSnapshot { + builtin: true, + id: id.into(), + name: name.into(), + core_version: "0.73.0".into(), + ..Default::default() + }, + outcome_detail: if recorded { + tw_api::OutcomeDetail::Recorded {} + } else { + tw_api::OutcomeDetail::Stripped { segments: 1 } + }, + } +} + +/// 危险命令的一次命中,没有切断(`blocked: false`) +fn flagged(provider: &str, rule: &str, action: tw_api::RuleAction) -> tw_api::Event { + tw_api::Event::ToolCallFlagged { id: 1, - provider: "relay".into(), + provider: provider.into(), tool: "Bash".into(), - rule: "rm-rf-root".into(), + rule: rule.into(), custom: false, why: "Deletes the whole home directory or the root".into(), excerpt: "rm -rf ~".into(), - action: tw_api::RuleAction::Record, + action, blocked: false, at_ms: T0, - detail: flag_detail(), + detail: hit_detail( + tw_api::SecurityDirection::Response, + rule, + "Delete home or root", + true, + ), + } +} + +/// 内容过滤的一次命中。`custom` 时规则名就是用户起的名字 +fn content_hit(provider: &str, rule: &str, custom: bool, recorded: bool) -> tw_api::Event { + tw_api::Event::ContentMatched { + id: 2, + provider: provider.into(), + rule: rule.into(), + custom, + matching: tw_api::ContentMatch::Contains, + action: if recorded { + tw_api::RuleAction::Record + } else { + tw_api::RuleAction::Strip + }, + outcome: if recorded { + tw_api::ContentOutcome::Recorded + } else { + tw_api::ContentOutcome::Stripped + }, + in_tool_result: false, + excerpt: "SECRET-CODENAME".into(), + count: 1, + revealed: None, + detail: hit_detail( + tw_api::SecurityDirection::Response, + rule, + "Jailbreak", + recorded, + ), + at_ms: T0, + } +} + +/// 密钥与隐私的一次请求里找到的几个值 +fn secrets(provider: &str, items: &[(&str, bool)]) -> tw_api::Event { + tw_api::Event::SecretsFound { + id: 3, + provider: provider.into(), + replaced: false, + items: items + .iter() + .map(|(rule, recorded)| tw_api::SecretItem { + rule: (*rule).into(), + custom: false, + kind: tw_api::SecretKind::Personal, + masked: "z…@example.com".into(), + count: 1, + detail: hit_detail( + tw_api::SecurityDirection::Request, + rule, + "Email address", + *recorded, + ), + }) + .collect(), + at_ms: T0, + } +} + +/// 「仅记录」= 记入日志并发出系统通知(规格 §3)。三项防护都说;**正文只有规则和上游**, +/// 不带命中的原文 +#[test] +fn a_record_only_hit_of_every_guard_is_told_with_rule_and_upstream_only() { + with_lang(Lang::Zh, || { + let tool = rules::from_event(&flagged("relay", "rm-rf-root", tw_api::RuleAction::Record)); + let content = rules::from_event(&content_hit("relay", "项目代号", true, true)); + let redact = rules::from_event(&secrets("relay", &[("email", true)])); + for (signals, key, name) in [ + ( + &tool, + "secwatch:inspect_tools:b:rm-rf-root", + "删除主目录或根目录", + ), + (&content, "secwatch:content:c:项目代号", "项目代号"), + (&redact, "secwatch:redact:b:email", "邮箱地址"), + ] { + assert_eq!(signals.len(), 1, "{key}"); + let s = &signals[0]; + assert_eq!(s.key, key); + assert_eq!(s.level, Level::Warning, "要发系统通知"); + assert!(s.event && !s.hold, "每次命中都是一件新的事,不去抖"); + assert!(s.merge.is_some(), "同时命中的要合成一条"); + assert!(s.title.contains(name), "{}", s.title); + assert!(s.body.contains("relay"), "{}", s.body); + assert_eq!(s.view, Some("security")); + for leak in ["rm -rf", "SECRET-CODENAME", "example.com"] { + assert!( + !s.body.contains(leak) && !s.title.contains(leak), + "{leak} 进了通知" + ); + } + } + // 防护名用新的叫法,回答一侧说「回答」 + assert!( + redact[0].body.starts_with("密钥与隐私:"), + "{}", + redact[0].body + ); + assert!( + content[0].body.contains("的回答命中"), + "{}", + content[0].body + ); + assert!(tool[0].body.starts_with("危险命令:"), "{}", tool[0].body); }); - assert!(signals.is_empty()); +} + +/// 防护处于「监控」时每条规则都按仅记录处理:「切断」规则命中了也没切,**照「仅记录」说**, +/// 不再说「可疑调用、建议在客户端拒绝」 +#[test] +fn a_cut_rule_in_monitor_is_told_like_record_only() { + let signals = rules::from_event(&flagged("relay", "curl-pipe-sh", tw_api::RuleAction::Cut)); + assert_eq!(signals.len(), 1); + assert!( + signals[0].key.starts_with("secwatch:"), + "{}", + signals[0].key + ); +} + +/// 改了内容的(替换为占位符、删除、替换)只记日志,不通知;一次请求里只有仅记录的那几项说 +#[test] +fn only_what_was_merely_recorded_is_told() { + assert!(rules::from_event(&content_hit("relay", "jailbreak", false, false)).is_empty()); + let s = rules::from_event(&secrets( + "relay", + &[("email", true), ("anthropic-api-key", false)], + )); + assert_eq!( + s.iter().map(|s| s.key.as_str()).collect::>(), + ["secwatch:redact:b:email"] + ); +} + +/// 几条规则同时命中:**合成一条通知**,逐条写规则和上游;列表里还是各是各的 +#[tokio::test(start_paused = true)] +async fn hits_that_arrive_together_are_told_in_one_notification() { + // 测试里缺省是中文(合成那一条在另一个任务里写,`with_lang` 管不到) + { + let b = bed(); + for s in rules::from_event(&content_hit("relay", "项目代号", true, true)) { + b.bus.ingest(s, T0); + } + for s in rules::from_event(&secrets("anthropic", &[("email", true)])) { + b.bus.ingest(s, T0 + 5); + } + assert!(b.titles().is_empty(), "要等同时到的几条一起说"); + wait(MERGE).await; + assert_eq!(b.titles(), ["2 条安全规则命中"], "{:?}", b.bodies()); + let body = b.bodies()[0].clone(); + assert!( + body.contains("「项目代号」· relay") && body.contains("「邮箱地址」· anthropic"), + "{body}" + ); + assert_eq!(b.bus.list().len(), 2, "列表里各是一条"); + } +} + +/// 同一条规则 5 分钟内至多通知一次;冷却结束时还有没说的,合成一条再说 +#[tokio::test(start_paused = true)] +async fn the_same_rule_is_told_at_most_once_in_five_minutes() { + let b = bed(); + let hit = || rules::from_event(&content_hit("relay", "项目代号", true, true)).remove(0); + b.bus.ingest(hit(), T0); + wait(MERGE).await; + assert_eq!(b.titles().len(), 1); + b.bus.ingest(hit(), T0 + 60_000); + wait(MERGE).await; + b.bus.ingest(hit(), T0 + 120_000); + wait(MERGE).await; + assert_eq!(b.titles().len(), 1, "冷却期间不再弹:{:?}", b.titles()); + assert_eq!(b.bus.list()[0].count, 3, "次数照记"); + wait(COOLDOWN).await; + wait(MERGE).await; + assert_eq!(b.titles().len(), 2, "冷却结束时说一次:{:?}", b.titles()); + // 另一条规则不受这一条的冷却影响 + for s in rules::from_event(&secrets("relay", &[("email", true)])) { + b.bus.ingest(s, T0 + 130_000); + } + wait(MERGE).await; + assert_eq!(b.titles().len(), 3, "{:?}", b.titles()); +} + +/// 只在应用内的提醒:列表里有,不弹 +#[tokio::test(start_paused = true)] +async fn in_app_only_a_record_only_hit_is_listed_but_not_shown() { + let b = bed(); + b.bus.set_mode(Mode::App); + for s in rules::from_event(&content_hit("relay", "项目代号", true, true)) { + b.bus.ingest(s, T0); + } + wait(MERGE).await; + assert!(b.titles().is_empty()); + assert_eq!(b.bus.list().len(), 1); +} + +/// 切断的那一条照旧单独、立刻说,不等合成 +#[tokio::test(start_paused = true)] +async fn the_cut_notification_is_kept_and_not_merged() { + let b = bed(); + b.bus.ingest(blocked("relay"), T0); + assert_eq!(b.titles().len(), 1, "切断要立刻说"); + assert!(b.bus.list()[0].key.starts_with("toolwall:")); } #[test] diff --git a/src/guide/guide.i18n.ts b/src/guide/guide.i18n.ts index b91c8bf8..b7a43d55 100644 --- a/src/guide/guide.i18n.ts +++ b/src/guide/guide.i18n.ts @@ -56,8 +56,8 @@ export const guideText = messages( // 安全页 /** 此刻停在「观察」的有几项 */ - observeTitle: (n: number) => `${n} 项防护处于「观察」`, - observeBody: "命中时只记录。在日志中确认没有误报后,再切换到「替换」「切断」或「处置」。", + observeTitle: (n: number) => `${n} 项防护处于「监控」`, + observeBody: "命中时只记录并发出通知。在日志中确认没有误报后,再切换为「开启」。", // 设置 hintsLabel: "引导提示", @@ -105,9 +105,9 @@ export const guideText = messages( openRowTitle: "Open a request for its details", openRowBody: "The routing rule it matched, the upstreams it tried, its usage and cost, and any redacted values.", - observeTitle: (n: number) => (n === 1 ? "1 protection is set to Observe" : `${n} protections are set to Observe`), + observeTitle: (n: number) => (n === 1 ? "1 protection is set to Monitor" : `${n} protections are set to Monitor`), observeBody: - "Matches are only recorded. Once the log shows no false positives, switch to Replace, Cut off or Enforce.", + "Matches are only recorded and notified. Once the log shows no false positives, switch to On.", hintsLabel: "Guidance", hintsHint: "Hints set to “Don’t show again” and ignored warnings reappear.", diff --git a/src/i18n/core.zh.json b/src/i18n/core.zh.json index 6ce53549..d3f92935 100644 --- a/src/i18n/core.zh.json +++ b/src/i18n/core.zh.json @@ -9,8 +9,8 @@ "rule": "规则", "group": "策略组", "gateway key": "网关密钥", - "redaction rule": "出站脱敏规则", - "tool-call rule": "工具调用审查规则", + "redaction rule": "密钥与隐私规则", + "tool-call rule": "危险命令规则", "content rule": "内容过滤规则", "plugin": "插件", "alias": "别名" @@ -21,8 +21,8 @@ "boolean": "true 或 false" }, "rule_line": { - "redaction": "出站脱敏", - "tool-call": "工具调用审查", + "redaction": "密钥与隐私", + "tool-call": "危险命令", "content": "内容过滤" }, "pattern_of": { @@ -115,8 +115,8 @@ "private_use": "私用区码位" }, "guard": { - "redact": "出站脱敏", - "inspect_tools": "工具调用审查", + "redact": "密钥与隐私", + "inspect_tools": "危险命令", "content": "内容过滤" }, "scan_rule": { @@ -142,6 +142,38 @@ "thinkwatch-data": "读写 ThinkWatch 自己的数据", "upload-file-to-host": "上传本地文件到外部主机" }, + "secret_rule": { + "anthropic-api-key": "Anthropic API 密钥", + "openai-api-key": "OpenAI API 密钥", + "openai-project-key": "OpenAI 项目密钥", + "github-personal-token": "GitHub 个人令牌", + "github-oauth-token": "GitHub OAuth 令牌", + "github-server-token": "GitHub 服务器令牌", + "github-user-token": "GitHub 用户令牌", + "github-fine-grained-token": "GitHub 细粒度令牌", + "slack-bot-token": "Slack 机器人令牌", + "slack-user-token": "Slack 用户令牌", + "slack-app-token": "Slack 应用令牌", + "aws-access-key-id": "AWS 访问密钥 ID", + "aws-temporary-key-id": "AWS 临时访问密钥 ID", + "google-api-key": "Google API 密钥", + "google-oauth-token": "Google OAuth 令牌", + "gitlab-token": "GitLab 令牌", + "stripe-live-key": "Stripe 生产密钥", + "stripe-restricted-key": "Stripe 受限密钥", + "npm-token": "npm 令牌", + "digitalocean-token": "DigitalOcean 令牌", + "sendgrid-key": "SendGrid 密钥", + "private-key": "私钥", + "jwt": "JWT", + "conn-string-password": "连接串口令", + "cn-resident-id": "居民身份证号", + "bank-card": "银行卡号", + "email": "邮箱地址", + "cn-mobile-phone": "中国大陆手机号", + "internal-ip": "内网地址", + "internal-domain": "内部域名" + }, "content_rule": { "unicode-tags": "Unicode 标签字符", "bidi-controls": "双向控制符", @@ -493,14 +525,14 @@ "security.unknown_rule": "没有名为「{rule}」的内置规则。", "security.rule_name_empty": "规则需要一个名称。", "security.bad_pattern": "正则表达式有误:{detail}", - "security.unknown_action": "「{action}」不是一种处置,只能是 cut 或 record。", - "security.content_action_unknown": "「{action}」不是一种处置,只能是 block、strip 或 record。", + "security.unknown_action": "「{action}」不是可用的动作,只能是 cut 或 record。", + "security.content_action_unknown": "「{action}」不是可用的动作,只能是 block、strip 或 record。", "security.bad_content_pattern": "匹配内容无法使用:{detail}", "security.bad_codepoints": "码位写法有误:{detail}", "security.bad_label": "占位符名称「{label}」不可用:须以大写字母开头,由 1 到 24 个大写字母、数字或下划线组成。", - "security.redact_match_bad": "出站脱敏规则只能按「包含」或「正则」匹配,不支持「{matching}」。", + "security.redact_match_bad": "密钥与隐私规则只能按「包含」或「正则」匹配,不支持「{matching}」。", "security.pattern_empty": "匹配内容为空。", - "security.no_action_of_its_own": "{guard:guard}的规则不单独设处置,命中后的处理由档位决定。", + "security.no_action_of_its_own": "{guard:guard}的规则不单独设动作,命中后的处理由分项状态决定。", "control.session_not_found": "未找到会话 {id}。", "control.session_not_running": "会话 {id} 没有正在进行的请求。", "control.client_unknown": "未知的客户端「{client}」。", @@ -684,8 +716,8 @@ "config.rule_pattern_empty": "自定义{what:rule_line}规则「{name}」的{what:pattern_of}为空。", "config.rule_pattern_bad": "自定义{what:rule_line}规则「{name}」的{what:pattern_of}有误:{detail}", "config.rule_codepoints_bad": "自定义内容过滤规则「{name}」的码位写法有误:{detail}", - "config.rule_label_bad": "自定义出站脱敏规则「{name}」的占位符名称为「{label}」,须以大写字母开头,由 1 到 24 个大写字母、数字或下划线组成。", - "config.rule_match_bad": "自定义出站脱敏规则「{name}」的匹配方式为「{matching}」,出站脱敏规则只能为 contains 或 regex。", + "config.rule_label_bad": "密钥与隐私的自定义规则「{name}」的占位符名称为「{label}」,须以大写字母开头,由 1 到 24 个大写字母、数字或下划线组成。", + "config.rule_match_bad": "密钥与隐私的自定义规则「{name}」的匹配方式为「{matching}」,密钥与隐私规则只能为 contains 或 regex。", "config.unknown_rule": "security.{guard} 中的「{rule}」不是内置规则。", "config.failover_range": "failover.{field} 为 {value},须在 {min} 到 {max} 之间。", "config.plugin.bad_id": "插件 ID「{plugin}」写法有误:只能使用小写字母、数字和连字符,1 到 {max} 个字符。", diff --git a/src/i18n/terminology.md b/src/i18n/terminology.md index d16bff36..321be533 100644 --- a/src/i18n/terminology.md +++ b/src/i18n/terminology.md @@ -100,14 +100,15 @@ known colloquialisms. | 尝试链 | attempts | | | 命中规则 / 经过策略组 | Matched rule / Via group | | | 格式转换 / 丢弃字段 | format conversion / dropped fields | | -| 出站脱敏 / 脱敏 / 已脱敏 | outbound redaction / redaction / Redacted | | -| 工具调用审查 | tool-call inspection | | -| 内容过滤 | content filter | hidden characters are one group of its built-in rules | +| 密钥与隐私 / 脱敏 / 已脱敏 | Secrets & privacy / redaction / Redacted | the protection (config key `security.redact`) | +| 危险命令 | Dangerous commands | the protection (config key `security.inspect_tools`) | +| 内容过滤 | Content filter | checks request text and answer text per rule (检查对象 / Checks); hidden characters are one group of its built-in rules | | 可疑工具调用 / 已拦截 | suspicious tool call / Blocked | traffic badges | | 配置面扫描 | config scan | scanning client configuration files | -| 关闭 / 观察 | Off / Observe | the first two modes of every protection | -| 替换 / 切断 / 处置 | Replace / Cut off / Enforce | the third mode, named per protection: redaction / tool-call inspection / content filter; counted together in the page header as 处置 (enforcing) | -| 拒绝 / 删除 / 仅记录 / 切断 | Refuse / Delete / Record only / Cut off | what a rule does in the third mode (column 处置 / Action) | +| 开启 / 监控 / 关闭 | On / Monitor / Off | the three states of every protection (`enforce` / `observe` / `off`); under 监控 every rule only records, and the action column adds 监控中 / Monitoring | +| 替换为占位符 / 切断 / 拒绝 / 删除 / 替换 / 仅记录 | Replace with placeholder / Cut off / Refuse / Delete / Replace / Record only | what a rule does when its protection is On (column 动作 / Action); 仅记录 also raises a system notification | +| 检查对象:请求 / 回答 / 请求和回答 | Checks: Request / Answer / Request and answer | content rules; 按请求测试 / 按回答测试 = Test as request / Test as answer | +| 替换文本 | Replacement text | content rules, default `***` | | 已拒绝 / 已删除 / 已替换 / 已切断 / 仅记录 | Refused / Deleted / Replaced / Cut off / Recorded | what happened, in the security log | | 匹配方式:包含 / 正则 / 码位 | Match by: Contains / Regex / Code points | content rules | | 占位符名称 | placeholder name | `<>` in redaction | diff --git a/src/labels.i18n.ts b/src/labels.i18n.ts index 8d003c4b..1f42a83a 100644 --- a/src/labels.i18n.ts +++ b/src/labels.i18n.ts @@ -1,4 +1,6 @@ import { messages } from "@/i18n"; +// 密钥与隐私的规则名和 core 消息的中文是同一张表(Rust 写系统通知时也读它) +import CORE_ZH from "@/i18n/core.zh.json"; /** `labels.ts` 里那些名称表和说法 */ export const labelsText = messages( @@ -114,38 +116,8 @@ export const labelsText = messages( }, // ------------------------------------------------------------ 安全 - secrets: { - "anthropic-api-key": "Anthropic API 密钥", - "openai-api-key": "OpenAI API 密钥", - "openai-project-key": "OpenAI 项目密钥", - "github-personal-token": "GitHub 个人令牌", - "github-oauth-token": "GitHub OAuth 令牌", - "github-server-token": "GitHub 服务器令牌", - "github-user-token": "GitHub 用户令牌", - "github-fine-grained-token": "GitHub 细粒度令牌", - "slack-bot-token": "Slack 机器人令牌", - "slack-user-token": "Slack 用户令牌", - "slack-app-token": "Slack 应用令牌", - "aws-access-key-id": "AWS 访问密钥 ID", - "aws-temporary-key-id": "AWS 临时访问密钥 ID", - "google-api-key": "Google API 密钥", - "google-oauth-token": "Google OAuth 令牌", - "gitlab-token": "GitLab 令牌", - "stripe-live-key": "Stripe 生产密钥", - "stripe-restricted-key": "Stripe 受限密钥", - "npm-token": "npm 令牌", - "digitalocean-token": "DigitalOcean 令牌", - "sendgrid-key": "SendGrid 密钥", - "private-key": "私钥", - jwt: "JWT", - "conn-string-password": "连接串口令", - "cn-resident-id": "居民身份证号", - "bank-card": "银行卡号", - email: "邮箱地址", - "cn-mobile-phone": "中国大陆手机号", - "internal-ip": "内网地址", - "internal-domain": "内部域名", - }, + /** 密钥与隐私的内置规则名。和系统通知(Rust)共用 core.zh.json 里的同一张表 */ + secrets: CORE_ZH.tables.secret_rule as Record, // ------------------------------------------------------------ 客户端接管 takesEffect: { diff --git a/src/overview/overview.i18n.ts b/src/overview/overview.i18n.ts index fa064d0a..0cc7a490 100644 --- a/src/overview/overview.i18n.ts +++ b/src/overview/overview.i18n.ts @@ -22,7 +22,7 @@ export const overviewText = messages( /** 失败过半集中在一个上游时点出它;全在一个上游时说「均」 */ attnFailed: (n: number, top: { name: string; n: number } | null) => `${n} 次请求失败` + (top ? (top.n === n ? `,均发生在 ${top.name}` : `,其中 ${top.name} ${top.n} 次`) : ""), - attnToolCut: (n: number) => `工具调用审查切断了 ${n} 个可疑工具调用`, + attnToolCut: (n: number) => `危险命令防护切断了 ${n} 个可疑工具调用`, attnUpstream: (name: string, why: string) => `${name} ${why}`, attnUpstreams: (n: number, names: string[]) => `${n} 个上游不可用:${names.slice(0, 3).join("、")}${names.length > 3 ? " 等" : ""}`, @@ -151,7 +151,7 @@ export const overviewText = messages( (n === 1 ? "1 request failed" : `${n} requests failed`) + (top ? (top.n === n ? (n === 1 ? ` on ${top.name}` : `, all on ${top.name}`) : `, ${top.n} of them on ${top.name}`) : ""), attnToolCut: (n: number) => - n === 1 ? "Tool-call inspection cut off 1 suspicious tool call" : `Tool-call inspection cut off ${n} suspicious tool calls`, + n === 1 ? "Dangerous-command protection cut off 1 suspicious tool call" : `Dangerous-command protection cut off ${n} suspicious tool calls`, attnUpstream: (name: string, why: string) => `${name}: ${why}`, attnUpstreams: (n: number, names: string[]) => `${n} upstreams unavailable: ${names.slice(0, 3).join(", ")}${names.length > 3 ? ", …" : ""}`, diff --git a/src/security/GuardTab.i18n.tsx b/src/security/GuardTab.i18n.tsx index 6754d9d2..bba14364 100644 --- a/src/security/GuardTab.i18n.tsx +++ b/src/security/GuardTab.i18n.tsx @@ -1,125 +1,88 @@ import { messages } from "@/i18n"; -import type { GuardMode } from "@/types"; - -/** 一项防护在三档下各做什么。**代价写在切换之前** */ -export interface GuardCopy { - /** 这项防护做什么,一句话 */ - lead: string; - now: Record; - /** 第三档在这一项上做的事 */ - effect: string; - /** 第三档的代价 */ - risk: string; -} +import type { Guard, GuardMode } from "@/types"; const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; export const guardTabText = messages( { - redact: { - lead: "请求发出前,按以下规则查找凭据和个人信息。", - now: { - off: "当前:不检查,不记录。", - observe: "当前:检出的内容记入日志,请求原样发出。", - enforce: "当前:检出的内容替换为占位符后发出,响应中的占位符还原为原值。", - }, - effect: "检出的内容替换为占位符后发出,响应中的占位符还原为原值。", - risk: "请求内容改变后,上游缓存可能无法命中。", - } as GuardCopy, - inspect_tools: { - lead: "检查上游返回的工具调用参数。", - now: { - off: "当前:不检查,不记录。", - observe: "当前:命中的调用记入日志,照常返回。", - enforce: - "当前:命中「切断」规则的调用不会完整到达客户端,因而无法执行;命中「仅记录」规则的调用照常返回。两类都记入日志。", - }, - effect: "命中「切断」规则的调用不会完整到达客户端,因而无法执行。", - risk: "误判时,回答会在该调用处中断。", - } as GuardCopy, - content: { - lead: "按以下规则检查请求中的用户消息和工具结果。", - now: { - off: "当前:不检查,不记录。", - observe: "当前:命中的内容记入日志,请求原样发出。", - enforce: - "当前:命中「拒绝」规则的请求不发出,客户端收到拒绝的原因;命中「删除」规则的内容删除后发出;命中「仅记录」规则的请求照常发出。三类都记入日志。", + /** + * 状态选择右边那一句:当前状态下命中之后怎样。「开启」各项一样(按每条规则的动作); + * 「监控」说清记录、通知、内容不变 + */ + now: { + enforce: "开启:命中内容按各条规则设定的动作处理。", + observe: { + redact: "监控:命中内容记入日志并发出通知,请求不作修改。", + inspect_tools: "监控:命中的工具调用记入日志并发出通知,照常返回客户端。", + content: "监控:命中内容记入日志并发出通知,请求与回答不作修改。", }, - effect: "命中「拒绝」规则的请求不发出,命中「删除」规则的内容删除后发出。", - risk: "误判时,正常的请求会被拒绝,或正常的文字被删除。", - } as GuardCopy, - /** 不在第三档时,档位下面那一句:切过去之后会怎样,代价是什么 */ - ifEnforced: (mode: string, effect: string, risk: string) => `切换到「${mode}」后:${effect}${risk}`, - rules: "规则", - ruleCount: (on: number, all: number) => `已启用 ${on} 条,共 ${all} 条`, + off: "关闭:不检查,不记录。", + } as { enforce: string; observe: Record; off: string }, + ruleCount: (on: number, all: number) => `已启用 ${on} 条,共 ${all} 条。`, + /** 规则表上方的检索框 */ + search: "检索规则", + noMatch: "无匹配的规则", test: "测试…", newRule: "新建规则", - rule: "规则", - match: "匹配", - /** 规则在第三档下做什么 */ - action: "处置", enabled: "启用", - builtinGroup: "内置", - view: "查看规则", + rule: "规则", + match: "匹配内容", + /** 内容规则查哪一边 */ + scope: "检查对象", + /** 规则在「开启」状态下做什么 */ + action: "动作", + /** 组标题:「隐藏字符 · 内置 · 已启用 2 条,共 3 条」 */ + builtin: "内置", + groupCount: (on: number, all: number) => `已启用 ${on} 条,共 ${all} 条`, + /** 组标题的复选框 */ + groupToggle: (group: string) => `启用「${group}」中的全部规则`, + /** 行内的动作选择,给读屏 */ + actionFor: (name: string) => `「${name}」的动作`, + /** 动作为「替换」时,行内选项写出换成什么 */ + replaceWith: (text: string) => `替换为 ${text}`, turnOn: "启用", turnOff: "停用", copyAsCustom: "复制为自定义规则", actionsFor: (name: string) => `${name} 的操作`, toggleFor: (name: string) => `启用「${name}」`, - modeFor: (guard: string) => `${guard}的档位`, + modeFor: (guard: string) => `${guard}的状态`, }, { - redact: { - lead: "Before a request is sent, it is searched for credentials and personal information using the rules below.", - now: { - off: "Currently: nothing is checked or recorded.", - observe: "Currently: what is found is recorded in the log, and the request is sent unchanged.", - enforce: - "Currently: what is found is replaced with placeholders before sending, and the placeholders in the response are restored to the original values.", - }, - effect: - "what is found is replaced with placeholders before sending, and the placeholders in the response are restored to the original values. ", - risk: "Once the request content changes, the upstream cache may no longer be hit.", - }, - inspect_tools: { - lead: "Tool-call arguments returned by the upstream are checked.", - now: { - off: "Currently: nothing is checked or recorded.", - observe: "Currently: matching calls are recorded in the log and returned as usual.", - enforce: - "Currently: calls that match a “cut off” rule never fully reach the client, so they cannot run; calls that match a “record only” rule are returned as usual. Both are recorded in the log.", - }, - effect: "calls that match a “cut off” rule never fully reach the client, so they cannot run. ", - risk: "On a false match, the answer stops at that call.", - }, - content: { - lead: "User messages and tool results in each request are checked against the rules below.", - now: { - off: "Currently: nothing is checked or recorded.", - observe: "Currently: matches are recorded in the log, and the request is sent unchanged.", - enforce: - "Currently: a request that matches a “refuse” rule is not sent, and the client is told why; text that matches a “delete” rule is deleted before sending; a request that matches a “record only” rule is sent as usual. All three are recorded in the log.", + now: { + enforce: "On: matches are handled by the action set on each rule.", + observe: { + redact: "Monitor: matches are recorded in the log and notified; requests are not changed.", + inspect_tools: + "Monitor: matching tool calls are recorded in the log and notified, and are returned to the client as usual.", + content: "Monitor: matches are recorded in the log and notified; requests and answers are not changed.", }, - effect: - "a request that matches a “refuse” rule is not sent, and text that matches a “delete” rule is deleted before sending. ", - risk: "On a false match, an ordinary request is refused, or ordinary text is deleted.", + off: "Off: nothing is checked or recorded.", }, - ifEnforced: (mode: string, effect: string, risk: string) => `After switching to ${mode}: ${effect}${risk}`, - rules: "Rules", - ruleCount: (on: number, all: number) => `${on} of ${plural(all, "rule", "rules")} on`, + ruleCount: (on: number, all: number) => `${on} of ${plural(all, "rule", "rules")} on.`, + search: "Search rules", + noMatch: "No matching rules", test: "Test…", newRule: "New rule", + enabled: "On", rule: "Rule", match: "Match", + scope: "Checks", action: "Action", - enabled: "On", - builtinGroup: "Built-in", - view: "View rule", + builtin: "Built-in", + groupCount: (on: number, all: number) => `${on} of ${all} on`, + groupToggle: (group: string) => `Turn on all rules in “${group}”`, + actionFor: (name: string) => `Action for “${name}”`, + replaceWith: (text: string) => `Replace with ${text}`, turnOn: "Turn on", turnOff: "Turn off", copyAsCustom: "Copy as a custom rule", actionsFor: (name: string) => `Actions for ${name}`, toggleFor: (name: string) => `Turn on “${name}”`, - modeFor: (guard: string) => `${guard} mode`, + modeFor: (guard: string) => `${guard} state`, }, ); + +/** 「开启」「监控」「关闭」下那一句 */ +export function nowText(t: typeof guardTabText.zh, guard: Guard, mode: GuardMode): string { + return mode === "observe" ? t.now.observe[guard] : t.now[mode]; +} diff --git a/src/security/GuardTab.tsx b/src/security/GuardTab.tsx index eedefb20..edeb2c00 100644 --- a/src/security/GuardTab.tsx +++ b/src/security/GuardTab.tsx @@ -1,8 +1,11 @@ -import { Fragment } from "react"; -import { FlaskConicalIcon, PlusIcon } from "lucide-react"; +import { Fragment, useMemo, useState } from "react"; +import { FlaskConicalIcon, PlusIcon, SearchIcon } from "lucide-react"; +import { Badge } from "@/ui/badge"; import { Button } from "@/ui/button"; +import { Checkbox } from "@/ui/checkbox"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "@/ui/input-group"; import { rowMotion, usePresentList } from "@/ui/motion"; -import { PageSection } from "@/ui/page"; +import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; import { RowMenu, RowMenuButton, type MenuItems } from "@/ui/row-menu"; import { Segmented } from "@/ui/segmented"; import { Skeleton } from "@/ui/skeleton"; @@ -14,25 +17,40 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { cn } from "@/lib/utils"; import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; -import type { Guard, GuardDetail, GuardMode, SecurityRuleView } from "@/types"; -import { hasAction, type ActionGuard } from "./api"; -import { Code, MatcherText, modeName, modeTone, ruleWhy, viewName } from "./labels"; +import { ACTIONS } from "./api"; +import { + actionOf, + DEFAULT_REPLACEMENT, + scopeOf, + type Guard, + type GuardDetail, + type GuardMode, + type RuleAction, + type SecurityRuleView, +} from "./contract-types"; +import { MatcherText, modeName, modeTone, ruleWhy, viewName } from "./labels"; import { securityLabelsText } from "./labels.i18n"; -import { guardTabText } from "./GuardTab.i18n"; +import { guardTabText, nowText } from "./GuardTab.i18n"; import { patternOf } from "./RuleDialog"; -import { GroupRow, ROW_FOCUS, rowNav, stop } from "./rows"; +import { actionAllowed, groupChecked, groupRules, groupTarget, ruleMatches, scopeChoice } from "./rules"; +import { ROW_FOCUS, rowNav, stop } from "./rows"; -const MODES: readonly GuardMode[] = ["off", "observe", "enforce"]; +/** 状态的先后,和规格的示意一致:开启、监控、关闭 */ +const MODES: readonly GuardMode[] = ["enforce", "observe", "off"]; /** 规则表上能做的事。**都由页面接住** —— 它们要写配置、要开对话框 */ export interface RuleActions { mode: (mode: GuardMode) => void; toggle: (r: SecurityRuleView, enabled: boolean) => void; - /** 这条规则的开关正在写 */ + /** 一组规则一起启用或停用(组标题的复选框) */ + bulk: (rules: SecurityRuleView[], enabled: boolean) => void; + /** 行内改一条规则的动作 */ + setAction: (r: SecurityRuleView, action: RuleAction) => void; + /** 这条规则正在写(开关、动作) */ pending: (r: SecurityRuleView) => boolean; - /** 内置规则:只读查看;自定义规则:编辑 */ + /** 打开对话框:内置规则改动作、试;自定义规则编辑 */ open: (r: SecurityRuleView) => void; - /** 复制成自定义规则。只有写得出等价写法的那几项有,而且只给写得出来的规则(见 `patternOf`) */ + /** 复制成自定义规则。只给写得出等价写法的规则(见 `patternOf`) */ copy?: (r: SecurityRuleView) => void; remove: (r: SecurityRuleView) => void; create: () => void; @@ -43,98 +61,77 @@ export interface RuleActions { const keyOf = (r: SecurityRuleView) => `${r.custom ? "c" : "b"}:${r.id}`; /** - * 一项防护的档位:名字、一句它做什么,右边三档;下面一行是现在的样子(状态点 - * 和页头、标签上的同色),再下面把切到第三档的代价说在切之前。 - * - * **第三档按这一项做的事命名**(替换、切断、处置),见 `modeName`。 + * 一项防护的状态:开启、监控、关闭,右边一句当前状态下命中之后怎样,以及启用了几条规则。 + * 圆点和页签上的同色。 */ -export function ModeCard({ +export function ModeBar({ guard, mode, + on, + all, pending, onMode, }: { guard: Guard; mode: GuardMode; - /** 档位正在写 */ + /** 启用的规则条数 */ + on: number; + all: number; + /** 状态正在写 */ pending: boolean; onMode: (mode: GuardMode) => void; }) { const t = useText(guardTabText); const lt = useText(securityLabelsText); - const copy = t[guard]; return ( -
-
-
-

{lt.guards[guard]}

-

{copy.lead}

-
-
- {pending && } - - label={t.modeFor(lt.guards[guard])} - value={mode} - options={MODES.map((m) => ({ id: m, label: modeName(guard, m) }))} - onChange={(v) => v !== mode && onMode(v)} - /> -
-
-
-

- - - {copy.now[mode]} - -

-

- {mode === "enforce" ? copy.risk : t.ifEnforced(modeName(guard, "enforce"), copy.effect, copy.risk)} -

-
+
+ + label={t.modeFor(lt.guards[guard])} + value={mode} + options={MODES.map((m) => ({ id: m, label: modeName(m) }))} + onChange={(v) => v !== mode && onMode(v)} + /> + {pending && } +

+ + + {nowText(t, guard, mode)}{" "} + {t.ruleCount(on, all)} + +

); } -/** 档位那一块和规则表还没读到时的样子:和读到之后同一个形状,不跳 */ +/** 状态那一行和规则表还没读到时的样子:和读到之后同一个形状,不跳 */ export function GuardSkeleton() { return (
-
-
-
- - -
- -
-
- - -
+
+ +
-
-
-
- - -
+
+
+
- +
); } /** - * 一项防护:上面是档位,下面是全部规则。 + * 一项防护:上面是状态,下面是全部规则。 + * + * **状态和规则都是全局的**,对所有上游、所有密钥一样。 * - * **档位和规则都是全局的**,对所有上游、所有密钥一样。这一页没有「按上游」 - * 的任何设置 —— 同一个请求走哪家,不该决定它里面的密钥会不会被换掉。 + * 规则的实际处理按这个顺序定(规格 §4.3):防护关闭不检查;规则停用不检查;防护处于 + * 「监控」时一律仅记录(动作列照写用户设的动作,后面标「监控中」);其余按规则的动作。 * - * 内置规则可以启停;工具调用和内容规则还能改第三档下的处置,改写法要先复制成 - * 自定义规则。自定义规则在对话框里改。每次改动由 core 写成一个配置版本。点一行 - * 打开它(内置的查看或改处置,自定义的编辑)。 + * 内置规则按类别分组,组标题上的复选框一次启用或停用这一组(部分启用时半选);自定义 + * 规则在最后一组。检索框按名字和写法筛。每一行的开关、动作就地改;点一行打开它的对话框。 */ export function GuardTab({ guard, @@ -148,40 +145,52 @@ export function GuardTab({ actions: RuleActions; }) { const t = useText(guardTabText); + const [query, setQuery] = useState(""); const on = detail.rules.filter((r) => r.enabled).length; return (
- + - - - - - } - > - {guard === "redact" ? ( - - ) : ( - - )} - +
+ + + + + setQuery(e.target.value)} + placeholder={t.search} + aria-label={t.search} + spellCheck={false} + /> + + + + +
+ +
); } -/** 行菜单:内置的查看或编辑、复制;自定义的编辑、删除 */ -function useMenu(guard: Guard, actions: RuleActions) { +/** 行菜单:内置的编辑、启停、复制;自定义的编辑、启停、删除 */ +function useMenu(actions: RuleActions) { const t = useText(guardTabText); const common = useText(commonText); return (r: SecurityRuleView): MenuItems => { @@ -198,12 +207,10 @@ function useMenu(guard: Guard, actions: RuleActions) { { kind: "sep" }, { kind: "item", label: common.delete, onSelect: () => actions.remove(r), danger: true }, ]; - // 出站脱敏的内置规则只能启停,也写不出等价的自定义规则 - if (!hasAction(guard)) return [{ kind: "item", label: t.view, onSelect: () => actions.open(r) }, toggle]; - // 代码里做的检查没有写法可抄 + // 写不出等价写法的(出站的凭据种类、代码里做的检查)没有可抄的 const copy = patternOf(r) ? actions.copy : undefined; return [ - // 内置的工具调用和内容规则能改第三档下的处置,所以是「编辑」不是「查看」 + // 内置规则能改动作,所以是「编辑」不是「查看」 { kind: "item", label: common.edit, onSelect: () => actions.open(r) }, toggle, ...(copy ? [{ kind: "sep" as const }, { kind: "item" as const, label: t.copyAsCustom, onSelect: () => copy(r) }] : []), @@ -211,45 +218,161 @@ function useMenu(guard: Guard, actions: RuleActions) { }; } -/** 按顺序把规则分成几段,每段一个标题。core 给的顺序就是界面的顺序 */ -function groups(rows: T[], key: (r: SecurityRuleView) => K) { - const out: { key: K; rows: T[] }[] = []; - for (const row of rows) { - const k = key(row.item); - const last = out[out.length - 1]; - if (last && last.key === k) last.rows.push(row); - else out.push({ key: k, rows: [row] }); - } - return out; +/** + * 全部规则,一张表:启用、规则、匹配内容、(内容过滤:检查对象)、动作。 + * + * **动作一列三种颜色**:拒绝、切断是红的(请求或回答的结局变了),删除、替换、替换为占位符 + * 是正文色(改了内容照常发出),仅记录是次要色。 + */ +function RuleTable({ + guard, + mode, + rules, + query, + actions, +}: { + guard: Guard; + mode: GuardMode; + rules: SecurityRuleView[]; + query: string; + actions: RuleActions; +}) { + const t = useText(guardTabText); + const lt = useText(securityLabelsText); + const menu = useMenu(actions); + const content = guard === "content"; + const span = content ? 6 : 5; + // 淡入淡出按全部规则算:检索筛掉的行直接不画,不播淡出 + const present = usePresentList(rules, keyOf); + const groups = useMemo(() => { + const shown = present.filter( + ({ item: r, presence }) => presence === "exit" || ruleMatches(r, viewName(guard, r), query), + ); + return groupRules(shown, (p) => p.item); + }, [present, guard, query]); + + return ( + + + + + + {content && } + + + + + + {t.enabled} + {t.rule} + {t.match} + {content && {t.scope}} + {t.action} + + + + + {groups.length === 0 && ( + + + {t.noMatch} + + + )} + {groups.map((g) => { + const live = g.rows.filter((p) => p.presence !== "exit").map((p) => p.item); + const title = (g.custom ? lt.kinds.custom : lt.kinds[g.key]) ?? g.key; + return ( + + actions.bulk(live, enabled)} + /> + {g.rows.map(({ item: r, key, presence }) => ( + + ))} + + ); + })} + +
+ ); } -function RuleSwitch({ r, name, actions }: { r: SecurityRuleView; name: string; actions: RuleActions }) { +/** + * 一组的标题行:复选框一次启用或停用组内(检索筛出来的)全部规则,部分启用时半选;后面是 + * 「隐藏字符 · 内置 · 已启用 2 条,共 3 条」。**复选框只是批量操作的入口**,组本身没有开关 + */ +function GroupHead({ + span, + title, + builtin, + rules, + pending, + onToggle, +}: { + span: number; + title: string; + /** 标题后面写「内置」(工具调用的内置组本来就叫「内置」,不重复) */ + builtin: boolean; + rules: SecurityRuleView[]; + pending: boolean; + onToggle: (enabled: boolean) => void; +}) { const t = useText(guardTabText); + const on = rules.filter((r) => r.enabled).length; return ( - actions.toggle(r, v)} - /> + + + onToggle(groupTarget(rules))} + /> + + + {title} + {builtin && ` · ${t.builtin}`} + {` · ${t.groupCount(on, rules.length)}`} + + ); } /** 一行规则:点它、按 Enter 打开,右键和行尾是同一份菜单 */ function RuleRow({ + guard, + mode, r, presence, items, actions, - children, }: { + guard: Guard; + mode: GuardMode; r: SecurityRuleView; presence: Parameters[0]; items: MenuItems; actions: RuleActions; - children: React.ReactNode; }) { + const t = useText(guardTabText); + const lt = useText(securityLabelsText); + const name = viewName(guard, r); + const why = ruleWhy(guard, r); + const pattern = r.matcher.kind === "regex" ? r.matcher.pattern : ""; return ( actions.open(r)} {...rowNav(() => actions.open(r))} > - {children} + + actions.toggle(r, v)} + /> + + +
{name}
+ {why && ( +
+ {why} +
+ )} +
+ {guard === "inspect_tools" && r.matcher.kind === "regex" ? ( + // 工具调用的正则又长又密:照写,悬停看全 + + {pattern} + + ) : ( + + + + )} + {guard === "content" && ( + + + {scopeOf(r).map((s) => ( + + {lt.directions[s]} + + ))} + + + )} + + + + + +
); } -/** 启用开关和行尾菜单那两格 */ -function RuleTail({ r, name, items, actions }: { r: SecurityRuleView; name: string; items: MenuItems; actions: RuleActions }) { - const t = useText(guardTabText); - return ( - <> - - - - - - - - ); -} - -function RuleName({ r, name, why }: { r: SecurityRuleView; name: string; why?: string }) { - return ( - <> -
{name}
- {why && ( -
- {why} -
- )} - - ); -} - -function RedactRules({ rules, actions }: { rules: SecurityRuleView[]; actions: RuleActions }) { - const t = useText(guardTabText); - const lt = useText(securityLabelsText); - const menu = useMenu("redact", actions); - const shown = usePresentList(rules, keyOf); - return ( - - - - - - - - - - {t.rule} - {t.match} - {t.enabled} - - - - - {groups(shown, (r) => r.kind).map((g) => ( - - 1 ? g.rows.length : undefined} /> - {g.rows.map(({ item: r, key, presence }) => { - const items = menu(r); - const name = viewName("redact", r); - return ( - - - - - - - - - - ); - })} - - ))} - -
- ); -} - /** - * 工具调用审查、内容过滤:规则、写法、第三档下做什么。 - * - * 工具调用的内置规则一组;内容规则按 core 给的类别分组(隐藏字符、指令覆盖、 - * 身份与提示词、中文说法),自定义的在最后。「匹配」一列:工具调用的正则照写,代码里 - * 做的检查(凭据发往陌生主机这类)说一句它查什么;码位规则写出码位范围。 - * - * **处置一列三种颜色**:拒绝、切断是红的(请求或回答的结局变了),删除是正文色(改了 - * 内容照常发出),仅记录是次要色。 + * 动作那一格:就地改的选择。**防护处于「监控」时照写用户设的动作**,后面淡色标「监控中」 —— + * 实际只记录,切回「开启」就按这里的动作处理。查回答的内容规则不能选「拒绝」(选项置灰) */ -function ActionRules({ guard, rules, actions }: { guard: ActionGuard; rules: SecurityRuleView[]; actions: RuleActions }) { +function ActionCell({ + guard, + mode, + r, + name, + actions, +}: { + guard: Guard; + mode: GuardMode; + r: SecurityRuleView; + name: string; + actions: RuleActions; +}) { const t = useText(guardTabText); const lt = useText(securityLabelsText); - const menu = useMenu(guard, actions); - const content = guard === "content"; - const key = (r: SecurityRuleView) => (r.custom ? "custom" : content ? r.kind : "builtin"); - const shown = usePresentList(rules, keyOf); + const action = actionOf(guard, r); + const scope = scopeChoice(scopeOf(r)); + const hard = action === "cut" || action === "block"; + const soft = action === "record"; + const label = (a: RuleAction) => + a === "replace" ? t.replaceWith(clipped(r.replacement ?? DEFAULT_REPLACEMENT)) : (lt.ruleActions[a] ?? a); return ( - - - - - - - - - - - {t.rule} - {t.match} - {t.action} - {t.enabled} - - - - - {groups(shown, key).map((g) => ( - - 1 ? g.rows.length : undefined} - /> - {g.rows.map(({ item: r, key: k, presence }) => { - const items = menu(r); - const name = viewName(guard, r); - const pattern = r.matcher.kind === "regex" ? r.matcher.pattern : ""; - const hard = r.action === "cut" || r.action === "block"; - const soft = r.action === "record" || r.action == null; - return ( - - - - - {content ? ( - - {/* 表里只写那段文字;「不区分大小写」对每一条都一样,在对话框里说 */} - {r.matcher.kind === "contains" ? {r.matcher.text} : } - - ) : r.matcher.kind === "regex" ? ( - - {pattern} - - ) : ( - // 代码里做的检查:说它查什么 - - - - )} - - {lt.ruleActions[r.action ?? "record"] ?? r.action} - - - - ); - })} - + + actions.setAction(r, e.target.value as RuleAction)} + > + {ACTIONS[guard].map((a) => ( + + {label(a)} + ))} - -
+ + {mode === "observe" && ( + {lt.monitoring} + )} + ); } + +/** 行内选项里的替换文字:太长的截一截,全文在对话框里 */ +function clipped(s: string): string { + return [...s].length > 12 ? `${[...s].slice(0, 12).join("")}…` : s; +} diff --git a/src/security/HitDetail.i18n.ts b/src/security/HitDetail.i18n.tsx similarity index 67% rename from src/security/HitDetail.i18n.ts rename to src/security/HitDetail.i18n.tsx index a2365526..e8dd6566 100644 --- a/src/security/HitDetail.i18n.ts +++ b/src/security/HitDetail.i18n.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { messages } from "@/i18n"; /** 英文的单复数 */ @@ -41,8 +42,24 @@ export const hitDetailText = messages( /** 已拒绝 */ notSent: "请求未发往上游。", clientReply: "客户端收到的回复", - /** 已删除:发出之前删去了几段 */ - stripped: (n: number) => `发往上游之前删除了 ${n.toLocaleString()} 处命中的内容。`, + /** 已删除:发出(或返回)之前删去了几段 */ + stripped: { + request: (n: number) => `删除 ${n.toLocaleString()} 处:命中内容在发往上游之前删除。`, + response: (n: number) => `删除 ${n.toLocaleString()} 处:命中内容在返回客户端之前删除。`, + }, + /** 内容过滤的「替换」:几处换成了规则指定的文字 */ + replacedText: { + request: (n: number, text: ReactNode) => ( + <> + 替换 {n.toLocaleString()} 处:命中内容在发往上游之前替换为 {text},不可还原。 + + ), + response: (n: number, text: ReactNode) => ( + <> + 替换 {n.toLocaleString()} 处:命中内容在返回客户端之前替换为 {text},不可还原。 + + ), + }, /** 实际发给上游的模型名(和客户端要的不同时) */ sentModel: (model: string) => `发往上游的模型为 ${model}`, openRequest: (id: number) => `打开请求 #${id}`, @@ -70,8 +87,26 @@ export const hitDetailText = messages( clientNotice: "Notice the client received", notSent: "The request was not sent to the upstream.", clientReply: "Reply the client received", - stripped: (n: number) => - `${count(n, "matched segment was", "matched segments were")} deleted before the request was sent.`, + stripped: { + request: (n: number) => + `${count(n, "matched segment was", "matched segments were")} deleted before the request was sent.`, + response: (n: number) => + `${count(n, "matched segment was", "matched segments were")} deleted before the answer reached the client.`, + }, + replacedText: { + request: (n: number, text: ReactNode) => ( + <> + {count(n, "matched segment was", "matched segments were")} replaced with {text} before the request was sent; this + cannot be restored. + + ), + response: (n: number, text: ReactNode) => ( + <> + {count(n, "matched segment was", "matched segments were")} replaced with {text} before the answer reached the + client; this cannot be restored. + + ), + }, sentModel: (model: string) => `sent to the upstream as ${model}`, openRequest: (id: number) => `Open request #${id}`, openSession: "View session", diff --git a/src/security/HitDetail.tsx b/src/security/HitDetail.tsx index faefa763..e648e82b 100644 --- a/src/security/HitDetail.tsx +++ b/src/security/HitDetail.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"; import { useText } from "@/i18n"; import { prettyJson } from "@/prettyJson"; import { clip } from "@/traffic/transcript"; -import type { HitLocation, OutcomeDetail, SecurityEventView } from "@/types"; +import { replacedOf, type HitLocation, type OutcomeDetail, type SecurityEventView } from "./contract-types"; import { Highlight, toneOf, type Mark } from "./Highlight"; import { Code, matchingOf, partLabel, snapshotName } from "./labels"; import { securityLabelsText } from "./labels.i18n"; @@ -214,15 +214,24 @@ function OutcomeText({ d, direction }: { d: OutcomeDetail; direction: SecurityEv switch (d.action) { case "recorded": return {t.recorded[direction]}; - case "replaced": + case "replaced": { + const r = replacedOf(d); + // 内容过滤的「替换」:几处换成了同一段文字,不可还原 + if (r.kind === "text") + return ( + + {t.replacedText[direction](r.segments, {r.replacement})} + + ); return ( {t.replacedWith} - {d.placeholders.map((p) => ( + {r.placeholders.map((p) => ( {p} ))} ); + } case "cut": return ( <> @@ -242,7 +251,7 @@ function OutcomeText({ d, direction }: { d: OutcomeDetail; direction: SecurityEv ); case "stripped": - return {t.stripped(d.segments)}; + return {t.stripped[direction](d.segments)}; } } diff --git a/src/security/LogTab.tsx b/src/security/LogTab.tsx index 1a70f4ab..baf8ddf4 100644 --- a/src/security/LogTab.tsx +++ b/src/security/LogTab.tsx @@ -17,7 +17,8 @@ import { KeyLabel } from "@/KeyLabel"; import { useText } from "@/i18n"; import { useNav } from "@/nav"; import RequestDrawer from "@/traffic/drawer/RequestDrawer"; -import { GUARDS, type Guard, type SecurityDetail, type SecurityEventView } from "@/types"; +import { GUARDS } from "@/types"; +import type { Guard, SecurityDetail, SecurityEventView } from "./contract-types"; import { api } from "./api"; import { fetchAll, fileName, toCsv, toJson, type ExportFormat } from "./export"; import { HitDetail } from "./HitDetail"; diff --git a/src/security/RuleDialog.i18n.ts b/src/security/RuleDialog.i18n.ts index caaebd17..675a855f 100644 --- a/src/security/RuleDialog.i18n.ts +++ b/src/security/RuleDialog.i18n.ts @@ -4,51 +4,54 @@ import { messages } from "@/i18n"; export const ruleDialogText = messages( { title: { - redact: { create: "新建脱敏规则", edit: "编辑脱敏规则" }, - inspect_tools: { create: "新建审查规则", edit: "编辑审查规则" }, + redact: { create: "新建密钥与隐私规则", edit: "编辑密钥与隐私规则" }, + inspect_tools: { create: "新建危险命令规则", edit: "编辑危险命令规则" }, content: { create: "新建内容规则", edit: "编辑内容规则" }, }, testTitle: { - redact: "测试出站脱敏", - inspect_tools: "测试工具调用审查", + redact: "测试密钥与隐私", + inspect_tools: "测试危险命令", content: "测试内容过滤", }, name: "名称", - nameHint: { - redact: "记入日志时显示此名称。", - inspect_tools: "记入日志和通知时显示此名称。", - content: "记入日志和拒绝原因时显示此名称。", - }, + nameHint: "记入日志及发出通知时显示此名称。", namePlaceholder: { redact: "内部令牌", inspect_tools: "删除集群资源", content: "项目代号", }, - /** 内容规则怎么认 */ + /** 内容规则查哪一边 */ + scope: "检查对象", + scopes: { + request: "请求", + response: "回答", + both: "请求和回答", + }, + scopeHint: "请求:用户消息与工具结果。回答:模型输出的正文,不含工具调用与思考过程。", + /** 规则怎么认 */ matchKind: "匹配方式", matchKinds: { contains: "包含", regex: "正则", codepoints: "码位", }, - patternLabel: { - contains: "匹配内容", - regex: "匹配(正则表达式)", - codepoints: "码位", - }, - toolHint: "按工具调用的参数匹配。", - /** 出站脱敏的自定义规则:怎么找、分不分大小写 */ - redactHint: { - contains: (cased: boolean) => - `在整个请求中按原样查找这段文字,. * 等字符没有特殊含义;${cased ? "区分" : "不区分"}大小写。找到的内容按凭据替换。`, - regex: (cased: boolean) => `按正则表达式匹配,${cased ? "区分" : "不区分"}大小写。匹配到的整段内容按凭据替换。`, - }, caseSensitive: "区分大小写", - contentHint: { - contains: "在用户消息和工具结果中查找这段文字,不区分大小写,首尾空格也参与匹配。", - regex: "在用户消息和工具结果中匹配,不区分大小写。", - codepoints: "码位或码位范围,多个之间用逗号分隔,如 U+200B, U+E0000–U+E007F。在用户消息和工具结果中查找这些字符。", + patternLabel: "匹配内容", + toolHint: "按工具调用的参数匹配,使用正则表达式。", + /** 匹配方式下面那一句。`cased`:区分大小写 */ + matchHint: { + contains: (cased: boolean) => + `按原文查找,「.」「*」等字符不作特殊处理;${cased ? "区分" : "不区分"}大小写。`, + regex: (cased: boolean) => `按正则表达式匹配;${cased ? "区分" : "不区分"}大小写。`, + codepoints: "码位或码位范围,多个之间用逗号分隔,如 U+200B, U+E0000–U+E007F。", }, + /** 检查对象含回答时,「码位」为何不可选 */ + codepointsRequestOnly: "码位仅适用于请求。", + /** 检查对象含回答的正则规则:流式回答只暂缓有限的末尾 */ + regexAnswerLimit: "回答中超过 256 个字符的命中,起始部分可能已送达客户端。", + /** 改了检查对象,选着的那一项不可选了,自动换成可选的 */ + switchedMatch: "检查对象含回答,匹配方式已改为「包含」。", + switchedAction: "检查对象含回答,动作已改为「删除」。", /** 码位输入框里的样子 */ codepointsExample: "U+200B, U+E0000–U+E007F", /** 码位写错时,输入框下面那一句 */ @@ -59,24 +62,35 @@ export const ruleDialogText = messages( order: (item: string) => `「${item}」的起点大于终点。`, count: (max: number) => `最多 ${max} 项。`, }, - /** 出站脱敏:换成的占位符叫什么 */ + /** 密钥与隐私:换成的占位符叫什么 */ label: "占位符名称", labelHint: "替换为 <>。只能使用大写字母、数字和下划线。", labelBad: "须以大写字母开头,只能使用大写字母、数字和下划线,最多 24 个字符。", match: "匹配", - /** 规则在第三档下做什么 */ - action: "处置", - /** 选了某一种处置时下面那一句 */ + /** 规则在「开启」状态下做什么 */ + action: "动作", + /** 选了某一种动作时下面那一句 */ actionWhat: { - cut: "命中的调用不会完整到达客户端,因而无法执行。", + placeholder: "命中内容替换为占位符后发出,回答中出现该占位符时还原为原值。", + cut: "命中的调用不完整送达客户端,客户端无法执行该调用。", block: "命中的请求不发出,客户端收到拒绝的原因。", - strip: "命中的文字从用户消息和工具结果中删除,请求照常发出。", + strip: "删除命中内容,不保留任何标记。", + replace: "命中内容替换为下方的替换文本。", record: { - inspect_tools: "命中的调用照常返回,只记入日志。", - content: "命中的请求照常发出,只记入日志。", + redact: "命中内容照常发出,记入日志并发出系统通知。", + inspect_tools: "命中的调用照常返回,记入日志并发出系统通知。", + content: "命中内容不作修改,记入日志并发出系统通知。", }, }, - /** 内置规则:出厂时的处置 */ + /** 检查对象含回答时,「拒绝」为何不可选 */ + blockRequestOnly: "拒绝仅适用于请求:回答以流式输出,已送达的内容无法撤回。", + /** 内容规则:换成什么 */ + replacement: "替换文本", + replacementHint: "命中内容替换为此文本,不可还原。", + replacementRequired: "请填写替换文本", + /** 内置规则选了「替换」:换成的文字固定 */ + builtinReplace: (text: string) => `命中内容替换为 ${text},不可还原。`, + /** 内置规则:出厂时的动作 */ factory: (what: string) => `出厂设置为「${what}」。`, state: "状态", on: "已启用", @@ -85,22 +99,29 @@ export const ruleDialogText = messages( replacedWith: "替换为", category: (kind: string) => `类别:${kind}`, sample: "测试文本", + /** 测试文本当作哪一边来试 */ + testAs: { + request: "按请求测试", + response: "按回答测试", + }, samplePlaceholder: { redact: "粘贴一段文本,例如一段含有密钥的环境变量", inspect_tools: "粘贴一段工具调用的参数,例如一条要执行的命令", content: "粘贴一段用户消息或工具结果", }, + sampleAnswer: "粘贴一段模型回答的正文", hits: (n: number) => `命中 ${n} 处`, rule: "规则", noHit: "未命中", content: "内容", - position: "位置", line: (n: number) => `第 ${n} 行`, testDesc: "按当前启用的规则检查一段文本,不发出任何请求。", - /** 测试结果:替换、删除之后真正发出去的那一份 */ - output: "发出的内容", + /** 测试结果:处理后的样子 */ + output: "处理后", /** 测试结果:有「拒绝」规则命中 */ - refused: (mode: string) => `在「${mode}」档下,此请求不会发出,客户端收到拒绝的原因。`, + refused: (mode: string) => `在「${mode}」状态下,此请求不会发出,客户端收到拒绝的原因。`, + /** 防护处于「监控」时,对话框底部那一句 */ + monitoring: "当前分项处于监控状态,规则仅记录命中,不修改内容。", builtin: "内置", copyAsCustom: "复制为自定义规则", nameRequired: "请填写名称", @@ -119,52 +140,49 @@ export const ruleDialogText = messages( }, { title: { - redact: { create: "New redaction rule", edit: "Edit redaction rule" }, - inspect_tools: { create: "New inspection rule", edit: "Edit inspection rule" }, + redact: { create: "New secrets & privacy rule", edit: "Edit secrets & privacy rule" }, + inspect_tools: { create: "New dangerous command rule", edit: "Edit dangerous command rule" }, content: { create: "New content rule", edit: "Edit content rule" }, }, testTitle: { - redact: "Test outbound redaction", - inspect_tools: "Test tool-call inspection", + redact: "Test secrets & privacy", + inspect_tools: "Test dangerous commands", content: "Test the content filter", }, name: "Name", - nameHint: { - redact: "Shown in the log.", - inspect_tools: "Shown in the log and in notifications.", - content: "Shown in the log and in the reason given for a refusal.", - }, + nameHint: "Shown in the log and in notifications.", namePlaceholder: { redact: "Internal token", inspect_tools: "Delete cluster resources", content: "Project codename", }, + scope: "Checks", + scopes: { + request: "Request", + response: "Answer", + both: "Request and answer", + }, + scopeHint: + "Request: user messages and tool results. Answer: the text the model writes, excluding tool calls and thinking.", matchKind: "Match by", matchKinds: { contains: "Contains", regex: "Regex", codepoints: "Code points", }, - patternLabel: { - contains: "Text to find", - regex: "Match (regular expression)", - codepoints: "Code points", - }, - toolHint: "Matched against the arguments of each tool call.", - redactHint: { - contains: (cased: boolean) => - `Looked for as written anywhere in the request; characters such as . and * have no special meaning. ${cased ? "Case must match" : "Case is ignored"}. What is found is replaced like a credential.`, - regex: (cased: boolean) => - `A regular expression; ${cased ? "case must match" : "case is ignored"}. Everything it matches is replaced like a credential.`, - }, caseSensitive: "Match case", - contentHint: { - contains: - "Searched for in user messages and tool results, ignoring case. Leading and trailing spaces count.", - regex: "Matched against user messages and tool results, ignoring case.", - codepoints: - "Code points or ranges, separated by commas, such as U+200B, U+E0000–U+E007F. These characters are searched for in user messages and tool results.", + patternLabel: "Match", + toolHint: "A regular expression matched against the arguments of each tool call.", + matchHint: { + contains: (cased: boolean) => + `Looked for as written; characters such as . and * have no special meaning. ${cased ? "Case must match" : "Case is ignored"}.`, + regex: (cased: boolean) => `A regular expression. ${cased ? "Case must match" : "Case is ignored"}.`, + codepoints: "Code points or ranges, separated by commas, such as U+200B, U+E0000–U+E007F.", }, + codepointsRequestOnly: "Code points apply to requests only.", + regexAnswerLimit: "In answers, the beginning of a match longer than 256 characters may already have reached the client.", + switchedMatch: "The rule checks answers, so the match was changed to Contains.", + switchedAction: "The rule checks answers, so the action was changed to Delete.", codepointsExample: "U+200B, U+E0000–U+E007F", codepointsBad: { syntax: (item: string) => `“${item}” is not a code point; write U+200B or U+E0000–U+E007F.`, @@ -179,14 +197,24 @@ export const ruleDialogText = messages( match: "Match", action: "Action", actionWhat: { - cut: "A matching call never fully reaches the client, so it cannot run. ", + placeholder: + "Matches are replaced with placeholders before sending; a placeholder that appears in the answer is restored to the original value. ", + cut: "A matching call never fully reaches the client, so the client cannot run it. ", block: "A matching request is not sent, and the client is told why. ", - strip: "Matching text is deleted from user messages and tool results, and the request is sent. ", + strip: "Matches are deleted without leaving any marker. ", + replace: "Matches are replaced with the replacement text below. ", record: { - inspect_tools: "A matching call is returned as usual and recorded in the log. ", - content: "A matching request is sent as usual and recorded in the log. ", + redact: "Matches are sent as usual, recorded in the log and notified. ", + inspect_tools: "A matching call is returned as usual, recorded in the log and notified. ", + content: "Matches are left unchanged, recorded in the log and notified. ", }, }, + blockRequestOnly: + "Refuse applies to requests only: answers are streamed, and text already delivered cannot be withdrawn.", + replacement: "Replacement text", + replacementHint: "Matches are replaced with this text, which cannot be restored.", + replacementRequired: "Enter the replacement text", + builtinReplace: (text: string) => `Matches are replaced with ${text}, which cannot be restored. `, factory: (what: string) => `The factory setting is “${what}”.`, state: "State", on: "On", @@ -194,20 +222,25 @@ export const ruleDialogText = messages( replacedWith: "Replaced with", category: (kind: string) => `Category: ${kind}`, sample: "Test text", + testAs: { + request: "Test as request", + response: "Test as answer", + }, samplePlaceholder: { redact: "Paste some text, such as environment variables that contain a key", inspect_tools: "Paste the arguments of a tool call, such as a command to run", content: "Paste a user message or a tool result", }, + sampleAnswer: "Paste the text of a model answer", hits: (n: number) => (n === 1 ? "1 match" : `${n} matches`), rule: "Rule", noHit: "No match", content: "Content", - position: "Position", line: (n: number) => `Line ${n}`, testDesc: "Checks a piece of text against the rules that are on. No request is sent.", - output: "What is sent", - refused: (mode: string) => `In ${mode} mode this request is not sent, and the client is told why.`, + output: "After processing", + refused: (mode: string) => `When the protection is ${mode}, this request is not sent, and the client is told why.`, + monitoring: "This protection is in Monitor: rules only record matches and change nothing.", builtin: "Built-in", copyAsCustom: "Copy as a custom rule", nameRequired: "Enter a name", diff --git a/src/security/RuleDialog.tsx b/src/security/RuleDialog.tsx index 7a76c120..bacc9900 100644 --- a/src/security/RuleDialog.tsx +++ b/src/security/RuleDialog.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { CaseSensitiveIcon, CopyIcon } from "lucide-react"; +import { CopyIcon } from "lucide-react"; import { AlertDialog, AlertDialogCancel, @@ -26,37 +26,56 @@ import { Spinner } from "@/ui/spinner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/ui/table"; import { Segmented } from "@/ui/segmented"; import { StatusLabel } from "@/ui/status-dot"; +import { Switch } from "@/ui/switch"; import { Textarea } from "@/ui/textarea"; -import { Toggle } from "@/ui/toggle"; import { cn } from "@/lib/utils"; import { useText } from "@/i18n"; import { commonText } from "@/i18n/common.i18n"; import { errorText } from "@/i18n/core.i18n"; -import type { ContentMatch, Guard, RuleAction, SecurityRuleView, SecurityTestHit } from "@/types"; -import { hasAction, type ActionGuard, type RuleSave } from "./api"; +import { ACTIONS, type RuleSave } from "./api"; import { checkCodepoints, DEFAULT_LABEL, labelOk, MAX_CODEPOINT_ITEMS, placeholderOf, type CodepointsProblem } from "./check"; +import { + actionOf, + DEFAULT_REPLACEMENT, + scopeOf, + type ContentMatch, + type Guard, + type GuardMode, + type RuleAction, + type RuleScope, + type SecurityRuleView, + type SecurityTestHit, +} from "./contract-types"; import { Highlight, lineOf, type Mark } from "./Highlight"; import { MatcherText, modeName, ruleName, ruleWhy, viewName } from "./labels"; import { securityLabelsText } from "./labels.i18n"; import { ruleDialogText } from "./RuleDialog.i18n"; +import { + actionAllowed, + adjustForScope, + coversAnswer, + matchAllowed, + scopeChoice, + scopeOfChoice, + type ScopeChoice, +} from "./rules"; import { useTrial, type Trial } from "./useTrial"; -/** 每一项防护的规则在第三档下能做的几件事,按选项的顺序 */ -const ACTIONS: Record = { - inspect_tools: ["cut", "record"], - content: ["block", "strip", "record"], -}; - -/** 自定义规则能选的写法:内容规则三种,出站脱敏不认码位。工具调用审查只有正则,不选 */ +/** 自定义规则能选的写法:内容规则三种,密钥与隐私不认码位。危险命令只有正则,不选 */ const MATCHES: Record<"redact" | "content", readonly ContentMatch[]> = { redact: ["contains", "regex"], content: ["contains", "regex", "codepoints"], }; +const SCOPES: readonly ScopeChoice[] = ["request", "response", "both"]; + +/** 新建的规则缺省做什么:内容规则仅记录(规格 §5.1),密钥与隐私替换为占位符,危险命令切断 */ +const NEW_ACTION: Record = { redact: "placeholder", inspect_tools: "cut", content: "record" }; + /** * 一条规则写的是什么,以及怎么认、分不分大小写。码位写成一行,和输入框里的写法一样。 * - * **写不出来的是 `null`**:出站脱敏那几种(前缀、PEM、身份证号…)和代码里做的检查 + * **写不出来的是 `null`**:密钥与隐私那几种(前缀、PEM、身份证号…)和代码里做的检查 * (`builtin`)没有一条能填进自定义规则的写法,所以也没有「复制为自定义规则」。 */ export function patternOf(r: SecurityRuleView): { pattern: string; match: ContentMatch; cased: boolean } | null { @@ -77,10 +96,11 @@ export interface RuleSeed { name: string; pattern: string; match?: ContentMatch; + cased?: boolean; action?: RuleAction; } -/** 一处命中标成什么样:会被切断、拒绝的红,会被删掉的划掉,会被替换或只记录的黄 */ +/** 一处命中标成什么样:会被切断、拒绝的红,会被删掉的划掉,替换和只记录的黄 */ function tone(action: string | null | undefined): Mark["tone"] { return action === "cut" || action === "block" ? "bad" : action === "strip" ? "strip" : "warn"; } @@ -90,6 +110,7 @@ function Field({ htmlFor, hint, error, + notes, children, }: { label: string; @@ -97,6 +118,8 @@ function Field({ hint?: string; /** 写错了:代替说明,红字 */ error?: string | null; + /** 说明下面再接的几句:不可选的原因(淡色)、自动换了哪一项(提醒色) */ + notes?: { text: string; tone?: "muted" | "warn" }[]; children: React.ReactNode; }) { return ( @@ -110,36 +133,59 @@ function Field({ ) : ( hint &&

{hint}

)} + {notes?.map((n) => ( +

+ {n.text} +

+ ))}
); } /** - * 第三档下做什么:工具调用是切断或仅记录,内容规则是拒绝、删除或仅记录。**自定义规则和 - * 内置规则用同一个** —— 内置规则提供的只是一条写法,命中之后怎么处置和自定义规则一样 - * 由用户定。 + * 规则在「开启」状态下做什么。**自定义规则和内置规则用同一个** —— 内置规则提供的只是一条 + * 写法,命中之后怎么处理由用户定。查回答的内容规则「拒绝」置灰(不隐藏),下面说原因; + * 换检查对象时自动换掉的,`switched` 说一句 */ function ActionField({ guard, value, onChange, + scope = "request", factory, + switched = false, + extra, }: { - guard: ActionGuard; + guard: Guard; value: RuleAction; onChange: (a: RuleAction) => void; - /** 内置规则出厂时的处置,标在下面那一句里 */ + scope?: ScopeChoice; + /** 内置规则出厂时的动作,标在下面那一句里 */ factory?: RuleAction | null; + switched?: boolean; + /** 代替动作说明的那一句(内置规则「替换」:换成的文字固定) */ + extra?: string; }) { const t = useText(ruleDialogText); const lt = useText(securityLabelsText); - const what = value === "record" ? t.actionWhat.record[guard] : t.actionWhat[value]; + const what = extra ?? (value === "record" ? t.actionWhat.record[guard] : t.actionWhat[value]); + const notes: { text: string; tone?: "muted" | "warn" }[] = []; + if (guard === "content" && coversAnswer(scope)) notes.push({ text: t.blockRequestOnly }); + if (switched) notes.push({ text: t.switchedAction, tone: "warn" }); return ( - + label={t.action} value={value} - options={ACTIONS[guard].map((a) => ({ id: a, label: lt.ruleActions[a] ?? a }))} + options={ACTIONS[guard].map((a) => ({ + id: a, + label: lt.ruleActions[a] ?? a, + disabled: !actionAllowed(guard, a, scope), + }))} onChange={onChange} /> @@ -160,10 +206,67 @@ function codepointsError(t: typeof ruleDialogText.zh, p: CodepointsProblem | nul } /** - * 试出来的结果:命中几处,在原文里标出来;替换、删除之后真正发出去的那一份;第三档下 - * 会不会被拒。 + * 测试文本:内容规则先选这段当作请求还是回答来试(规则不查的那一边置灰),下面是文本框 + * 和试出来的结果 + */ +function SampleField({ + id, + guard, + sample, + onSample, + side, + onSide, + sides, + children, +}: { + id: string; + guard: Guard; + sample: string; + onSample: (s: string) => void; + side: RuleScope; + onSide: (s: RuleScope) => void; + /** 规则查的那几边。不给(测试全部规则、非内容规则)就两边都能选 */ + sides?: readonly RuleScope[]; + children: React.ReactNode; +}) { + const t = useText(ruleDialogText); + const content = guard === "content"; + return ( + + {content && ( + + label={t.sample} + value={side} + options={(["request", "response"] as const).map((s) => ({ + id: s, + label: t.testAs[s], + disabled: sides != null && !sides.includes(s), + }))} + onChange={onSide} + /> + )} +