diff --git a/README.md b/README.md index acfa8dd3..5932c524 100644 --- a/README.md +++ b/README.md @@ -42,16 +42,18 @@ before the client runs them. Cursor, Continue and Antigravity CLI come with instructions. Switching upstreams then happens in the gateway alone. - **Protection against relays.** A relay sees every request in full and can - rewrite every answer. Outbound redaction swaps API keys, private keys, JWTs, + rewrite every answer. Secrets & privacy swaps API keys, private keys, JWTs, connection-string passwords, Chinese resident ID numbers and bank card numbers for placeholders before a request leaves, so the relay never holds the real values. When an answer carries a tool call that downloads and runs code, sends out environment variables or credential files, reads private keys or installs a startup item or scheduled job, - tool-call inspection cuts the answer off before the client can run it. + Dangerous commands cuts the answer off before the client can run it. The content filter deletes instructions hidden in invisible characters before - a request leaves and can refuse prompt injection. Each protection starts in - Observe, which only records, and is switched over one at a time. + a request leaves, can refuse prompt injection, and can delete or replace + chosen words in requests and in the model's answers. Each protection starts in + Monitor, which records and notifies, and is switched on one at a time; every + rule has its own action. - **Upstreams, cross-checked.** Each upstream is compared with the others serving the same model: answers naming a different model, reported input well above or below theirs and low prompt-cache reads are marked on its row in the diff --git a/README.zh-CN.md b/README.zh-CN.md index 0ef9a0b6..e0f6a2dc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,7 +31,7 @@ Claude Code、Codex 等 AI 客户端的本地网关,支持 macOS、Windows 与 ## 要点 - **一次接入,随时切换。** Claude Code、Claude Desktop、Codex、opencode、Pi、oh-my-pi、Grok Build、Qwen Code、Hermes Agent、Zed、Aider 与 DeepSeek Harness 可一键指向网关,写入前预览改动、备份原文件,随时可以还原;Cursor、Continue 与 Antigravity CLI 提供配置说明。此后切换上游只在网关中完成。 -- **防范中转站。** 中转站能看到请求的全部内容,也能改写每一次回答。出站脱敏在请求发出前把 API 密钥、私钥、JWT、连接串口令、身份证号与银行卡号换成占位符,中转站拿不到原值。回答中若出现下载即执行、外发环境变量或凭据文件、读取私钥、写入开机启动项或定时任务之类的工具调用,工具调用审查会在客户端执行之前切断回答。内容过滤在请求发出前删除藏在不可见字符里的指令,也可以直接拒绝提示注入。各项防护出厂只记录,逐项切换后生效。 +- **防范中转站。** 中转站能看到请求的全部内容,也能改写每一次回答。「密钥与隐私」在请求发出前把 API 密钥、私钥、JWT、连接串口令、身份证号与银行卡号换成占位符,中转站拿不到原值。回答中若出现下载即执行、外发环境变量或凭据文件、读取私钥、写入开机启动项或定时任务之类的工具调用,「危险命令」会在客户端执行之前切断回答。「内容过滤」在请求发出前删除藏在不可见字符里的指令,可以直接拒绝提示注入,也可以删除或替换请求和模型回答中的指定词语。各项防护出厂处于「监控」状态,只记录并发出通知,逐项开启后生效;每条规则可单独设定动作。 - **上游互相对照。** 每个上游都与服务同一模型的其他上游对照:回答中的模型名与发出的不同、报告的输入明显偏多或偏少、提示缓存读取偏低,都会在上游列表的那一行标出,悬停可见证据与样本数。 - **扫描 MCP、技能与钩子。** 十三款客户端的 MCP 服务器并列显示并标出第三方服务器;客户端配置、技能、钩子与项目指令中的隐藏字符、提示注入、危险命令与过宽权限会被找出。 - **每个请求都可追溯。** 命中的规则、尝试过的每个上游、API 格式转换与费用的计算依据都在请求详情中;已结束的请求可以重放到另一个上游,并排对比。全部请求记录都可以搜索,包括请求与回答的内容。 diff --git a/package.json b/package.json index af275ae2..003a5fd4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "thinkwatch-lite", "private": true, "description": "Desktop app for a local AI API gateway on macOS, Windows and Linux", - "version": "2026.10.18", + "version": "2026.10.19", "type": "module", "packageManager": "pnpm@11.13.0", "scripts": { diff --git a/release-notes/2026.10.19.md b/release-notes/2026.10.19.md new file mode 100644 index 00000000..91f7ba97 --- /dev/null +++ b/release-notes/2026.10.19.md @@ -0,0 +1,24 @@ +**Upgrade notes:** +- The bundled core is now 0.73.0, with control-plane protocol 48. A remote server must run core 0.73.0; servers on 0.72.x do not connect until they are upgraded. +- Nothing is cleared; the request history, stored payloads and settings carry over, and every existing rule keeps its behaviour. + +**Security page:** +- The three protections are now named for what they guard against: **Secrets & privacy** (formerly Outbound redaction), **Dangerous commands** (formerly Tool-call inspection) and **Content filter**. Each has three states, **On**, **Monitor** and **Off**, shown as a dot on its tab. +- **Monitor** records every match in the log and raises a system notification, and changes nothing. **Record only** on a single rule does the same under On. The same rule notifies at most once every 5 minutes, matches arriving together are merged into one notification, and a notification never contains the matched text. +- Every rule has its own switch and action, set directly in the list: Secrets & privacy rules replace with a placeholder or record only; Dangerous commands rules cut off or record only; Content filter rules refuse, delete, replace or record only. While a protection is in Monitor, rules show their action with "Monitoring" next to it. +- Built-in rules are grouped by category, with a checkbox on each group to switch all of its rules at once, and a search box filters rules by name and pattern. +- **Match case** in the Secrets & privacy rule dialog is now a switch. + +**Content filter for answers:** +- A content rule now says what it checks: **Request**, **Answer** or **Request and answer**. A rule on answers checks the text the model writes, including Codex's freeform tool input; tool calls stay with Dangerous commands, and thinking is not checked. +- **Replace** writes the rule's replacement text (default `***`) in place of the match, on requests and answers. It is not undone in the answer. **Delete** removes the match without a trace. +- Answers keep streaming: only the few characters that could still become a match are held back. A rule on answers cannot refuse and cannot match code points; those options are disabled in the dialog with the reason given. +- The test box tries a sample as a request or as an answer. +- While a rule on answers can delete or replace, token probabilities (logprobs) are left out of streamed answers. + +**Log and traffic:** +- Matches in answers show where they were found, and the outcome says how many places were deleted or replaced. Requests whose text was replaced carry a **Replaced** mark in the traffic list. +- Secrets that a Record only rule let through are counted as such instead of being shown as redacted. + +**Fixed:** +- Rule dialogs fit small windows and scroll inside instead of pushing the buttons out of view. diff --git a/scripts/shots/mock/core.ts b/scripts/shots/mock/core.ts index 0d08879d..03b5658f 100644 --- a/scripts/shots/mock/core.ts +++ b/scripts/shots/mock/core.ts @@ -199,6 +199,7 @@ export const CORE: { [N in WebviewEndpoint]: Handler } = { SetSecurityMode: refuse, ToggleBuiltinRule: refuse, SetBuiltinRuleAction: refuse, + SetRulesEnabled: refuse, CreateCustomRule: refuse, UpdateCustomRule: refuse, DeleteCustomRule: refuse, diff --git a/scripts/shots/mock/traffic.ts b/scripts/shots/mock/traffic.ts index c5b60bb4..301ecc01 100644 --- a/scripts/shots/mock/traffic.ts +++ b/scripts/shots/mock/traffic.ts @@ -657,6 +657,7 @@ export function summary(from: number, to = Infinity): Summary { content: count("content"), content_blocked: count("content", ["blocked"]), content_stripped: count("content", ["stripped"]), + content_replaced: count("content", ["replaced"]), }, pricing_date: AS_OF, }; diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 292a5939..ae6d126a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4832,7 +4832,7 @@ dependencies = [ [[package]] name = "thinkwatch-lite" -version = "2026.10.18" +version = "2026.10.19" dependencies = [ "anyhow", "block2", @@ -5335,8 +5335,8 @@ dependencies = [ [[package]] name = "tw-api" -version = "0.72.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.72.0#226a9bd816d2c1aa81c5bc6bf0eca6ea79cbe383" +version = "0.73.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" dependencies = [ "serde", "serde_json", @@ -5347,8 +5347,8 @@ dependencies = [ [[package]] name = "tw-dialect" -version = "0.72.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.72.0#226a9bd816d2c1aa81c5bc6bf0eca6ea79cbe383" +version = "0.73.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" dependencies = [ "memchr", "serde", @@ -5357,13 +5357,15 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.72.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.72.0#226a9bd816d2c1aa81c5bc6bf0eca6ea79cbe383" +version = "0.73.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" dependencies = [ + "aho-corasick", "base64 0.22.1", "bytes", "memchr", "regex", + "regex-syntax", "serde", "serde_json", "serde_yaml_ng", @@ -5374,8 +5376,8 @@ dependencies = [ [[package]] name = "tw-link" -version = "0.72.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.72.0#226a9bd816d2c1aa81c5bc6bf0eca6ea79cbe383" +version = "0.73.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" dependencies = [ "serde", "serde_json", @@ -5401,8 +5403,8 @@ dependencies = [ [[package]] name = "tw-types" -version = "0.72.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.72.0#226a9bd816d2c1aa81c5bc6bf0eca6ea79cbe383" +version = "0.73.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" dependencies = [ "serde", "ts-rs", @@ -5410,8 +5412,8 @@ dependencies = [ [[package]] name = "tw-watch" -version = "0.72.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.72.0#226a9bd816d2c1aa81c5bc6bf0eca6ea79cbe383" +version = "0.73.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" dependencies = [ "libc", "notify", @@ -5421,8 +5423,8 @@ dependencies = [ [[package]] name = "tw-yaml" -version = "0.72.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.72.0#226a9bd816d2c1aa81c5bc6bf0eca6ea79cbe383" +version = "0.73.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" dependencies = [ "saphyr-parser", "thiserror 2.0.21", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c0f4016c..88920950 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "thinkwatch-lite" -version = "2026.10.18" +version = "2026.10.19" edition = "2024" # **这个数决定依赖能升到哪一版。**edition 2024 的解析器只挑声明的 Rust # 版本编得动的依赖:写 1.85 的时候,`cargo update` 一直停在旧的 time 和 @@ -26,12 +26,12 @@ license = "MIT" # twcore 二进制必须和这里编译进去的协议镜像来自同一个 core 版本 —— 打包脚本正是 # 从 tw-api 锁到的 tag 去取二进制的(见 scripts/fetch-core.sh)。 [workspace.dependencies] -tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.72.0" } -tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.72.0" } -tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.72.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.72.0" } -tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.72.0" } -tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.72.0" } +tw-api = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } +tw-types = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } +tw-yaml = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } +tw-watch = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } +tw-link = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } [lib] name = "thinkwatch_lite_lib" diff --git a/src-tauri/src/call.rs b/src-tauri/src/call.rs index 1d316bd9..099f3474 100644 --- a/src-tauri/src/call.rs +++ b/src-tauri/src/call.rs @@ -148,6 +148,7 @@ webview_endpoints![ SetSecurityMode, ToggleBuiltinRule, SetBuiltinRuleAction, + SetRulesEnabled, CreateCustomRule, UpdateCustomRule, DeleteCustomRule, 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..ed5964b9 100644 --- a/src-tauri/src/notices/tests.rs +++ b/src-tauri/src/notices/tests.rs @@ -849,23 +849,260 @@ 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(), + direction: tw_api::SecurityDirection::Response, + 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, + replaced: !*recorded, + 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-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 909cd0bb..b5b8168d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ThinkWatch Lite", - "version": "2026.10.18", + "version": "2026.10.19", "identifier": "app.thinkwatch.lite", "build": { "beforeDevCommand": "pnpm dev", diff --git a/src/control.ts b/src/control.ts index e0e85b47..01b20dec 100644 --- a/src/control.ts +++ b/src/control.ts @@ -100,6 +100,7 @@ export const WEBVIEW_ENDPOINTS = [ "SetSecurityMode", "ToggleBuiltinRule", "SetBuiltinRuleAction", + "SetRulesEnabled", "CreateCustomRule", "UpdateCustomRule", "DeleteCustomRule", diff --git a/src/events.test.ts b/src/events.test.ts index 8e04f459..6317034e 100644 --- a/src/events.test.ts +++ b/src/events.test.ts @@ -745,21 +745,24 @@ describe("对账时行对象换不换", () => { }); /** - * 内容过滤:**只有删过文字的进这一行**(「已删除」徽标)。拒绝的随后有一条失败事件, - * 只记录的照常发出,都不用在列表上说;`applyBatch` 也只为删过的那一条换新对象。 + * 内容过滤:**只有删过、换过文字的进这一行**(「已删除」「已替换」徽标)。拒绝的随后有 + * 一条失败事件,只记录的照常发出,都不用在列表上说;`applyBatch` 也只为改过的那几条 + * 换新对象。 */ - it("内容过滤删过文字的记在这一行,拒绝和只记录的不记", () => { + it("内容过滤删过、换过文字的记在这一行,拒绝和只记录的不记", () => { const rows = new Map(); applyEvent(rows, started()); - const matched = (outcome: "recorded" | "stripped" | "blocked", rule: string) => + const matched = (outcome: "recorded" | "stripped" | "replaced" | "blocked", rule: string) => ({ kind: "content_matched", id: 1, provider: "relay", + direction: outcome === "replaced" ? "response" : "request", rule, custom: false, match: "codepoints", - action: outcome === "stripped" ? "strip" : outcome === "blocked" ? "block" : "record", + action: + outcome === "stripped" ? "strip" : outcome === "replaced" ? "replace" : outcome === "blocked" ? "block" : "record", outcome, in_tool_result: true, excerpt: "summarize ‹U+E0049 ×74› the diff", @@ -774,6 +777,8 @@ describe("对账时行对象换不换", () => { outcome_detail: outcome === "stripped" ? { action: "stripped", segments: 1 } + : outcome === "replaced" + ? { action: "replaced", placeholders: [], segments: 2, replacement: "***" } : outcome === "blocked" ? { action: "blocked", client_notice: "[ThinkWatch] refused" } : { action: "recorded" }, @@ -784,8 +789,14 @@ describe("对账时行对象换不换", () => { expect(rows.get(1)).toBe(before); expect(applyBatch(rows, [matched("stripped", "unicode-tags")])).toBe(true); expect(rows.get(1)).not.toBe(before); + const afterStrip = rows.get(1); + expect(applyBatch(rows, [matched("replaced", "project")])).toBe(true); + expect(rows.get(1)).not.toBe(afterStrip); applyEvent(rows, matched("blocked", "jailbreak")); - expect(rows.get(1)?.stripped).toEqual([{ rule: "unicode-tags", custom: false, count: 74 }]); + expect(rows.get(1)?.stripped).toEqual([ + { rule: "unicode-tags", custom: false, count: 74, replaced: false }, + { rule: "project", custom: false, count: 74, replaced: true }, + ]); }); }); diff --git a/src/generated/tw-api.ts b/src/generated/tw-api.ts index 2dcfeb09..8cfac321 100644 --- a/src/generated/tw-api.ts +++ b/src/generated/tw-api.ts @@ -1,6 +1,6 @@ // Generated by tw-api (`tw_api::ts::export_all`). Do not edit by hand. -export const CONTROL_API_VERSION = 47; +export const CONTROL_API_VERSION = 48; /** * 手动中止(`POST /request/{id}/abort`、`POST /sessions/{id}/abort`)叫停了哪几个请求。 @@ -36,12 +36,12 @@ email?: string | null, plan?: ChatgptPlan | null, }; /** - * 改一条内置规则在第三档下做什么。只有工具调用审查和内容过滤的规则有这一项 —— - * 出站脱敏的规则命中就替换。 + * 改一条内置规则在第三档下做什么。 */ export type ActionSave = { /** - * 工具调用审查:`cut` / `record`;内容过滤:`block` / `strip` / `record` + * 出站脱敏:`placeholder` / `record`;工具调用审查:`cut` / `record`;内容过滤:`block` / + * `strip` / `replace` / `record` */ action: RuleAction, base_version?: string | null, }; @@ -904,7 +904,12 @@ export type ContentMatch = "contains" | "regex" | "codepoints"; /** * 一条命中最后怎么样了。 */ -export type ContentOutcome = "recorded" | "stripped" | "blocked"; +export type ContentOutcome = "recorded" | "stripped" | "replaced" | "blocked"; + +/** + * 一条内容规则查哪一边。 + */ +export type ContentScope = "request" | "response"; /** * 正文里的哪一边。 @@ -1061,18 +1066,29 @@ export type CustomRuleSave = { name: string, */ pattern: string, /** - * 工具调用审查:`cut` / `record`;内容过滤:`block` / `strip` / `record`。不给按 `record` + * 出站脱敏:`placeholder` / `record`,不给按 `placeholder`;工具调用审查:`cut` / + * `record`;内容过滤:`block` / `strip` / `replace` / `record`(查回答的不能 `block`)。 + * 后两项不给按 `record` */ action?: RuleAction | null, /** - * 怎么认。内容过滤:`contains`(不分大小写的子串)/ `regex` / `codepoints`;出站脱敏: - * `contains`(按原样找这段文字)/ `regex`。不给按 `contains`。工具调用审查的都是正则 + * 怎么认。内容过滤:`contains`(按原样找这段文字)/ `regex` / `codepoints`(只查请求); + * 出站脱敏:`contains` / `regex`。不给按 `contains`。工具调用审查的都是正则 */ match?: ContentMatch | null, /** - * 出站脱敏才有:分大小写。不给是不分 + * 出站脱敏、内容过滤:分大小写。不给是不分 */ case_sensitive?: boolean | null, +/** + * 内容过滤才有:查哪一边,`request` / `response`,两边都查的两个都给。不给是 + * `["request"]` + */ +scope?: Array | null, +/** + * 内容过滤才有:动作是替换时换成的文字。不给是 `***` + */ +replacement?: string | null, /** * 出站脱敏才有:占位符名称,`PROJECT` 换成 `<>`。大写字母开头,其余是 * 大写字母、数字、下划线,最多 24 个字符。不给是 `SECRET` @@ -1486,9 +1502,13 @@ billing: Billing, */ egress?: string | null, } | { "kind": "content_matched", id: number, /** - * 这时要发往的上游(故障转移之前的首选) + * 请求里的:这时要发往的上游(故障转移之前的首选)。回答里的:回答它的那一家 */ provider: string, +/** + * 命中在请求里(`request`)还是回答的正文里(`response`) + */ +direction: SecurityDirection, /** * 内置规则的 id,或者自定义规则的名字 */ @@ -1498,7 +1518,7 @@ rule: string, custom: boolean, */ match: ContentMatch, /** - * 这条规则在处置档下做什么:`block` / `strip` / `record` + * 这条规则在处置档下做什么:`block` / `strip` / `replace` / `record` */ action: RuleAction, /** @@ -1506,16 +1526,16 @@ action: RuleAction, */ outcome: ContentOutcome, /** - * 第一处在工具结果里,而不是调用方自己打的字 + * 第一处在工具结果里,而不是调用方自己打的字。回答里的是 false */ in_tool_result: boolean, /** - * 第一处前后的一小段,**已截断**。码位规则命中的字符画成 `‹U+E0049›`,连成一串 - * 的写成 `‹U+E0049 ×12›` + * 第一处前后的一小段,**已截断、已打码**。码位规则命中的字符画成 `‹U+E0049›`,连成 + * 一串的写成 `‹U+E0049 ×12›` */ excerpt: string, /** - * 这条规则在整个请求里命中了几处;码位规则是几个字符 + * 这条规则在整个请求(或者这次回答)里命中了几处;码位规则是几个字符 */ count: number, /** @@ -2593,12 +2613,12 @@ base_version?: string | null, }; */ export type Matcher = { "kind": "prefix", prefix: string, min_tail: number, } | { "kind": "openai-legacy", min_len: number, } | { "kind": "pem" } | { "kind": "jwt" } | { "kind": "conn-string" } | { "kind": "private-ip" } | { "kind": "domain-suffix", suffixes: Array, } | { "kind": "cn-resident-id", born_since: number, } | { "kind": "bank-card", networks: Array, } | { "kind": "email" } | { "kind": "cn-mobile-phone" } | { "kind": "regex", pattern: string, /** - * 分大小写:工具调用审查的规则(开头写了 `(?i)` 的除外)、写明了要分的出站脱敏 - * 自定义规则。内容过滤的不分 + * 分大小写:工具调用审查的规则(开头写了 `(?i)` 的除外)、写明了要分的出站脱敏和 + * 内容过滤的自定义规则。内容过滤的内置规则不分 */ case_sensitive?: boolean, } | { "kind": "contains", text: string, /** - * 分大小写:写明了要分的出站脱敏自定义规则。内容过滤的不分 + * 分大小写:写明了要分的自定义规则。内容过滤的内置规则不分 */ case_sensitive?: boolean, } | { "kind": "codepoints", ranges: Array, } | { "kind": "builtin", check: string, }; @@ -2899,7 +2919,7 @@ export type OnProxyFail = "fail" | "direct"; * 具体做了什么([`SecurityEventView::outcome_detail`]),按 `action` 分,和 * [`SecurityOutcome`] 一一对应。 */ -export type OutcomeDetail = { "action": "recorded", } | { "action": "replaced", placeholders: Array, } | { "action": "cut", tool: string, arguments: string, truncated: boolean, client_notice: string, } | { "action": "blocked", client_notice: string, } | { "action": "stripped", segments: number, }; +export type OutcomeDetail = { "action": "recorded", } | { "action": "replaced", placeholders: Array, segments?: number, replacement?: string, } | { "action": "cut", tool: string, arguments: string, truncated: boolean, client_notice: string, } | { "action": "blocked", client_notice: string, } | { "action": "stripped", segments: number, }; /** * 界面要显示的配置概览。 @@ -4219,10 +4239,10 @@ denied_by?: string | null, affinity?: AffinityView | null, attempts: Array, }; /** - * 一条规则在第三档下做什么。工具调用审查是 `cut` / `record`,内容过滤是 `block` / - * `strip` / `record`;出站脱敏的规则命中即替换,没有这一项。 + * 一条规则在第三档下做什么。出站脱敏是 `placeholder` / `record`,工具调用审查是 `cut` / + * `record`,内容过滤是 `block` / `strip` / `replace` / `record`。 */ -export type RuleAction = "cut" | "block" | "strip" | "record"; +export type RuleAction = "placeholder" | "cut" | "block" | "strip" | "replace" | "record"; /** * 一条命中的规则起了什么作用。 @@ -4399,6 +4419,20 @@ phase_two: boolean, */ shadowed: boolean, }; +/** + * 一次启用或停用一项防护里的几条规则(组标题上的复选框):内置的按 id,自定义的按名字, + * **写一次配置**。和出厂一样的不写进文件。 + */ +export type RulesToggle = { +/** + * 内置规则的 id + */ +builtin: Array, +/** + * 自定义规则的名字 + */ +custom: Array, enabled: boolean, base_version?: string | null, }; + /** * 一个在跑的请求。 */ @@ -4463,6 +4497,11 @@ kind: SecretKind, * 只留最后四位(`…1234`),邮箱只留第一个字和域名(`z…@example.com`) */ masked: string, count: number, +/** + * 换成了占位符:拦截档下、规则的动作是换成占位符。`false` 是只记录(观察档,或者规则的 + * 动作是「仅记录」),原值照发 + */ +replaced: boolean, /** * 每一处在哪儿、当时的规则、具体做了什么(见 [`SecurityHitDetail`]) */ @@ -4504,7 +4543,11 @@ content_blocked: number, /** * 其中命中的文字删掉之后发出的 */ -content_stripped: number, }; +content_stripped: number, +/** + * 其中命中的文字换成替换文本之后发出的 + */ +content_replaced: number, }; /** * 三项防护。 @@ -4521,7 +4564,7 @@ export type SecurityDirection = "request" | "response"; * * **一条是一次命中**:出站脱敏是「一个请求里的一个值」(出现几次合成 * 一条,`count` 说几次),工具调用审查是「一个工具调用命中一条规则」,内容过滤是 - * 「一个请求命中一条规则」。 + * 「一个请求(或者一次回答)命中一条规则」。 */ export type SecurityEventView = { id: number, at_ms: number, request_id: number, guard: Guard, /** @@ -4529,8 +4572,9 @@ export type SecurityEventView = { id: number, at_ms: number, request_id: number, */ rule: string, custom: boolean, /** - * 做了什么:`recorded`(只记录)/ `replaced`(已替换)/ `cut`(已切断)/ - * `stripped`(命中的文字删掉之后发出)/ `blocked`(请求被拒,没有发出去) + * 做了什么:`recorded`(只记录)/ `replaced`(出站脱敏换成了占位符,内容过滤换成了 + * 替换文本)/ `cut`(已切断)/ `stripped`(命中的文字删掉之后发出)/ `blocked`(请求被拒, + * 没有发出去) */ action: SecurityOutcome, /** @@ -4592,7 +4636,7 @@ session?: string | null, */ sent_model?: string | null, /** - * 查的是请求(出站脱敏、内容过滤)还是回答(工具调用审查) + * 查的是请求(出站脱敏、内容过滤)还是回答(工具调用审查、查回答的内容过滤) */ direction: SecurityDirection, /** @@ -4717,7 +4761,8 @@ kind: string, matcher: Matcher, enabled: boolean, */ on_by_default: boolean, /** - * 工具调用审查、内容过滤:第三档下做什么 + * 第三档下做什么:出站脱敏 `placeholder` / `record`,工具调用审查 `cut` / `record`, + * 内容过滤 `block` / `strip` / `replace` / `record` */ action?: RuleAction | null, /** @@ -4728,7 +4773,17 @@ default_action?: RuleAction | null, * 出站脱敏:占位符里的标签,`SECRET` 换成 `<>`。内置和自定义的都有, * 别的防护没有 */ -label?: string | null, }; +label?: string | null, +/** + * 内容过滤:查哪一边,`request` / `response`,两边都查的两个都在。内置规则只查请求。 + * 别的防护没有 + */ +scope?: Array | null, +/** + * 内容过滤:动作是替换时换成的文字(不写是 `***`,这里写明)。内置规则是 `***`。 + * 别的防护没有 + */ +replacement?: string | null, }; /** * 试出来的一处。 @@ -4749,7 +4804,7 @@ start: number, end: number, */ excerpt: string, /** - * 工具调用审查、内容过滤:第三档下做什么 + * 第三档下做什么 */ action?: RuleAction | null, }; @@ -4767,7 +4822,7 @@ pattern?: string | null, */ match?: ContentMatch | null, /** - * 出站脱敏试 `pattern` 时分不分大小写。不给是不分 + * 出站脱敏、内容过滤试 `pattern` 时分不分大小写。不给是不分 */ case_sensitive?: boolean | null, /** @@ -4781,12 +4836,21 @@ rule?: string | null, label?: string | null, /** * 试的这一条在第三档下做什么,`output` 和 `refused` 按它算:试一条还没存的规则 - * (`pattern`),或者预览一条内置规则改了处置之后(`rule`)。工具调用审查是 `cut` / - * `record`,内容过滤是 `block` / `strip` / `record`,出站脱敏没有这一项。不给就按配置 - * 里的处置(还没存的规则按自定义规则不写处置时的那个:仅记录)。都没给 `pattern`、 - * `rule` 时用不上 + * (`pattern`),或者预览一条内置规则改了动作之后(`rule`)。出站脱敏是 `placeholder` / + * `record`,工具调用审查是 `cut` / `record`,内容过滤是 `block` / `strip` / `replace` / + * `record`。不给就按配置里的动作(还没存的规则按自定义规则不写动作时的那个:出站脱敏 + * 换成占位符,别的仅记录)。都没给 `pattern`、`rule` 时用不上 */ -action?: RuleAction | null, }; +action?: RuleAction | null, +/** + * 内容过滤:样本当成哪一边测,`request`(调用方发来的正文,不给就是它)或者 `response` + * (模型回答的正文)。按回答测时只用查回答的规则,试的 `pattern` 也当成查回答的规则 + */ +scope?: ContentScope | null, +/** + * 内容过滤试 `pattern`、动作是替换时换成的文字。不给是 `***` + */ +replacement?: string | null, }; /** * 试的结果。 @@ -4797,8 +4861,8 @@ export type SecurityTestResult = { */ hits: Array, /** - * 第三档下发出去的样子:出站脱敏是换过占位符的样本,内容过滤是删过的样本。没有 - * 变化(或者内容过滤会拒绝这个请求)是 null + * 第三档下发出去的样子:出站脱敏是换过占位符的样本,内容过滤是删过、换过的样本(按 + * 回答测时是客户端收到的那一段)。没有变化(或者内容过滤会拒绝这个请求)是 null */ output: string | null, /** @@ -5597,6 +5661,7 @@ export const ENDPOINTS = { SetSecurityMode: { method: "PUT", path: "/security/{guard}/mode", params: ["guard"], format: "json" }, ToggleBuiltinRule: { method: "PUT", path: "/security/{guard}/builtin/{id}", params: ["guard", "id"], format: "json" }, SetBuiltinRuleAction: { method: "PUT", path: "/security/{guard}/builtin/{id}/action", params: ["guard", "id"], format: "json" }, + SetRulesEnabled: { method: "PUT", path: "/security/{guard}/rules/enabled", params: ["guard"], format: "json" }, CreateCustomRule: { method: "POST", path: "/security/{guard}/custom", params: ["guard"], format: "json" }, UpdateCustomRule: { method: "PUT", path: "/security/{guard}/custom/{name}", params: ["guard", "name"], format: "json" }, DeleteCustomRule: { method: "DELETE", path: "/security/{guard}/custom/{name}", params: ["guard", "name"], format: "json" }, @@ -5726,6 +5791,7 @@ export type Endpoints = { SetSecurityMode: { req: ModeSave; res: ConfigWritten }; ToggleBuiltinRule: { req: RuleToggle; res: ConfigWritten }; SetBuiltinRuleAction: { req: ActionSave; res: ConfigWritten }; + SetRulesEnabled: { req: RulesToggle; res: ConfigWritten }; CreateCustomRule: { req: CustomRuleSave; res: ConfigWritten }; UpdateCustomRule: { req: CustomRuleSave; res: ConfigWritten }; DeleteCustomRule: { req: BaseVersion; res: ConfigWritten }; 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..e97cd479 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,18 @@ "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_bad": "「{action}」不是可用的动作,只能是 block、strip、replace 或 record。", + "security.redact_action_unknown": "「{action}」不是可用的动作,只能是 placeholder 或 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.unknown_custom_rule": "没有名为「{rule}」的自定义规则。", + "security.rule_scope_empty": "内容规则须至少检查请求或回答中的一项。", + "security.rule_scope_block": "检查回答的规则不能拒绝:发现命中时,回答已在发送途中。请选择删除、替换或仅记录。", + "security.rule_scope_codepoints": "码位只在请求中检查,检查回答的规则须按「包含」或「正则」匹配。", "control.session_not_found": "未找到会话 {id}。", "control.session_not_running": "会话 {id} 没有正在进行的请求。", "control.client_unknown": "未知的客户端「{client}」。", @@ -684,8 +720,11 @@ "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_scope_empty": "自定义内容过滤规则「{name}」的检查对象(scope)为空,须至少包含 request 或 response。", + "config.rule_scope_block": "自定义内容过滤规则「{name}」检查回答,动作却为 block。发现命中时回答已在发送途中,检查回答的规则只能为 strip、replace 或 record。", + "config.rule_scope_codepoints": "自定义内容过滤规则「{name}」检查回答,匹配方式却为 codepoints。码位只在请求中检查,检查回答的规则只能为 contains 或 regex。", + "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/overview/series.test.ts b/src/overview/series.test.ts index 1a775468..6083232e 100644 --- a/src/overview/series.test.ts +++ b/src/overview/series.test.ts @@ -46,6 +46,7 @@ function summary(over: Partial = {}): Summary { content: 0, content_blocked: 0, content_stripped: 0, + content_replaced: 0, }, pricing_date: "2026-09-20", ...over, 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..079c84cc 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,41 @@ 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 type { Guard, GuardDetail, GuardMode, RuleAction, SecurityRuleView } from "@/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, + actionOf, + DEFAULT_REPLACEMENT, + groupChecked, + groupRules, + groupTarget, + ruleMatches, + scopeChoice, + scopeOf, +} 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 +62,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 +146,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 +208,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 +219,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..fd9ee126 100644 --- a/src/security/HitDetail.tsx +++ b/src/security/HitDetail.tsx @@ -6,6 +6,7 @@ import { useText } from "@/i18n"; import { prettyJson } from "@/prettyJson"; import { clip } from "@/traffic/transcript"; import type { HitLocation, OutcomeDetail, SecurityEventView } from "@/types"; +import { replacedOf } from "./rules"; import { Highlight, toneOf, type Mark } from "./Highlight"; import { Code, matchingOf, partLabel, snapshotName } from "./labels"; import { securityLabelsText } from "./labels.i18n"; @@ -214,15 +215,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 +252,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..8cf5ffd3 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 "@/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..0283db7e 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 type { + ContentMatch, + ContentScope, + Guard, + GuardMode, + RuleAction, + SecurityRuleView, + SecurityTestHit, +} from "@/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, + actionOf, + adjustForScope, + coversAnswer, + DEFAULT_REPLACEMENT, + matchAllowed, + scopeChoice, + scopeOf, + 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: ContentScope; + onSide: (s: ContentScope) => void; + /** 规则查的那几边。不给(测试全部规则、非内容规则)就两边都能选 */ + sides?: readonly ContentScope[]; + 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} + /> + )} +