From 22b69c5d5be0184a2b2920419059c96abb3487e3 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:58:04 +0800 Subject: [PATCH] Scan alerts: a finding that only moved to another line is not reported again Editing ~/.codex/config.toml (a line added above, a model changed) moved every remote MCP server below it, and the watcher reported all of them again as new: a finding was recognised by path, rule, line and the line's text. Each finding now carries a subject that does not depend on where it is in the file: the server's name and address for a remote MCP server, the matched text for a rule, the line's text for hidden characters, the skill's name for over-broad tools. The watcher compares path, rule and subject, counted, so a second identical finding is still new. The seen set keeps only hashes, so it holds nothing from the files. Co-Authored-By: Claude Opus 5.5 --- src-tauri/crates/tw-scan/src/report.rs | 13 +++++ src-tauri/crates/tw-scan/src/watch.rs | 66 ++++++++++++++++++++------ src-tauri/src/scan.rs | 43 +++++++++++++++++ 3 files changed, 108 insertions(+), 14 deletions(-) diff --git a/src-tauri/crates/tw-scan/src/report.rs b/src-tauri/crates/tw-scan/src/report.rs index 699408b7..61da3222 100644 --- a/src-tauri/crates/tw-scan/src/report.rs +++ b/src-tauri/crates/tw-scan/src/report.rs @@ -61,6 +61,12 @@ pub struct Finding { pub detail: Msg, /// 命中的那一行。不可见字符已经换成可见记号 pub excerpt: String, + /// 这条发现说的是什么,**和它在文件里的位置无关**:远端 MCP 是服务器名和地址, + /// 规则命中是命中的那一段,藏起来的字符是那一行,skill 是它的名字。 + /// + /// 「是不是新出现的」按它认([`crate::watch::Seen`]),不按行号:文件上面多一行、 + /// 少一行,下面的每一条都会挪位置,按行号认就会把早就看过的整批再报一遍 + pub subject: String, } /// 一个 MCP server 在某个客户端里的样子。 @@ -703,6 +709,7 @@ fn hidden_finding(src: &Source, h: hidden::Kind, line: usize, excerpt: String) - why, src.kind.why() ), + subject: excerpt.clone(), excerpt, } } @@ -729,6 +736,8 @@ fn not_all_checked(src: &Source, line: usize, listed: usize) -> Finding { => "It holds too many to list one by one. The first {count} are listed; from line {line} on it was not checked for them." ), excerpt: String::new(), + // 一份文件只会有一条 + subject: String::new(), } } @@ -772,6 +781,7 @@ pub fn scan(sources: &[Source], rules: &Rules) -> Report { => "That server is at {url}, and using it sends the surrounding context there." ), excerpt, + subject: format!("{}|{}", m.name, m.url.as_deref().unwrap_or("")), }); } } @@ -807,6 +817,7 @@ pub fn scan(sources: &[Source], rules: &Rules) -> Report { title: msg!("scan.skill.all_tools", name = name.clone() => "skill `{name}` declares allowed-tools: [\"*\"]"), detail: msg!("scan.skill.all_tools.detail" => "That skill may use any tool. It may well need to; it is worth confirming that it does."), excerpt, + subject: name.clone(), }); } r.skills.push(SkillEntry { @@ -855,6 +866,7 @@ pub fn scan(sources: &[Source], rules: &Rules) -> Report { continue; }; let (line, excerpt) = line_of(&text, m.as_str()); + let subject = format!("{}|{}", of.slug(), m.as_str()); // `msg!` 会把 `rule`、`kind` 遮住,所以句子要用到的几段先取出来 let why = rule.why.clone(); let name = rule.name.clone(); @@ -889,6 +901,7 @@ pub fn scan(sources: &[Source], rules: &Rules) -> Report { kind_why ), excerpt, + subject, }); } } diff --git a/src-tauri/crates/tw-scan/src/watch.rs b/src-tauri/crates/tw-scan/src/watch.rs index 2798f645..871b54d2 100644 --- a/src-tauri/crates/tw-scan/src/watch.rs +++ b/src-tauri/crates/tw-scan/src/watch.rs @@ -18,7 +18,7 @@ //! //! **盯目录不盯文件、不递归、去抖**,和配置文件的监听是同一份([`tw_watch`])。 -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -260,26 +260,38 @@ fn is_ours(p: &Path) -> bool { /// 全文留在内存里没有任何必要。 #[derive(Debug, Default)] pub struct Seen { - /// 每条发现的指纹 - known: HashSet, + /// 每条发现的指纹(只存哈希,不留文件里的任何内容),和这个指纹此刻有几条 + known: HashMap, /// 扫过了没有。**第一次扫的结果不算「新出现」** —— 否则用户第一次 /// 打开就会被一屏「新发现」砸中,而那些东西可能在他机器上放了半年 primed: bool, } -fn key(f: &crate::report::Finding) -> String { - format!("{}|{}|{}|{}", f.path.display(), f.rule, f.line, f.excerpt) +/// **不带行号**:认的是哪份文件、哪条规则、说的是什么([`crate::report::Finding::subject`])。 +/// 改一下 `~/.codex/config.toml`,下面的 MCP 服务器整体挪了几行,它们还是那几条 +fn key(f: &crate::report::Finding) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (&f.path, &f.rule, &f.subject).hash(&mut h); + h.finish() } impl Seen { /// 吃掉一次扫描结果,返回**这次新出现的那些**。 + /// + /// 同一个指纹按条数算:原来有一条、现在有两条,多出来的那一条是新的 pub fn diff(&mut self, findings: &[crate::report::Finding]) -> Vec { - let fresh: Vec<_> = findings - .iter() - .filter(|f| !self.known.contains(&key(f))) - .cloned() - .collect(); - self.known = findings.iter().map(key).collect(); + let mut now: HashMap = HashMap::new(); + let mut fresh = Vec::new(); + for f in findings { + let k = key(f); + let n = now.entry(k).or_default(); + *n += 1; + if *n > self.known.get(&k).copied().unwrap_or(0) { + fresh.push(f.clone()); + } + } + self.known = now; if !self.primed { self.primed = true; return Vec::new(); @@ -308,6 +320,7 @@ mod tests { title: tw_types::msg!("t.title" => "t"), detail: tw_types::msg!("t.detail" => "d"), excerpt: "e".into(), + subject: "s".into(), } } @@ -344,11 +357,34 @@ mod tests { } #[test] - fn the_same_finding_moving_to_another_line_counts_as_new() { - // 行号变了通常意味着文件被编辑过 —— 那正是我们想知道的时刻。 + fn the_same_finding_moving_to_another_line_is_not_new() { + // 改一下 `~/.codex/config.toml`,上面多一行,下面的 MCP 服务器整体往下挪: + // 它们还是早就报过的那几条,不该再报一遍。 + let mut seen = Seen::default(); + seen.diff(&[f("a", "remote-mcp", 1), f("a", "tag", 3)]); + assert!( + seen.diff(&[f("a", "remote-mcp", 12), f("a", "tag", 40)]) + .is_empty() + ); + } + + #[test] + fn something_else_under_the_same_rule_is_new() { + // 同一份文件、同一条规则,说的是另一个东西(另一台远端服务器):是新的。 + let mut seen = Seen::default(); + seen.diff(&[f("a", "remote-mcp", 1)]); + let mut other = f("a", "remote-mcp", 1); + other.subject = "other|https://mcp.example.com".into(); + assert_eq!(seen.diff(&[f("a", "remote-mcp", 2), other]).len(), 1); + } + + #[test] + fn one_more_of_the_same_is_new() { + // 原来一条,现在两条一模一样的:多出来的那一条是新的。 let mut seen = Seen::default(); seen.diff(&[f("a", "tag", 1)]); - assert_eq!(seen.diff(&[f("a", "tag", 40)]).len(), 1); + assert_eq!(seen.diff(&[f("a", "tag", 1), f("a", "tag", 5)]).len(), 1); + assert!(seen.diff(&[f("a", "tag", 2), f("a", "tag", 9)]).is_empty()); } #[test] @@ -358,6 +394,8 @@ mod tests { let mut secret = f("a", "tag", 1); secret.detail = tw_types::msg!("t.detail" => "这里有一段很私密的提示词内容"); secret.title = tw_types::msg!("t.title" => "标题里也有私密内容"); + secret.excerpt = "私密的那一行".into(); + secret.subject = "私密的那一行".into(); seen.diff(&[secret]); let dump = format!("{:?}", seen); assert!(!dump.contains("私密"), "{dump}"); diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index 4b7f6b55..c6f99230 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -387,6 +387,49 @@ mod tests { assert_eq!(rules_of(&alerts), vec!["zero_width".to_string()]); } + /// 改一下 `~/.codex/config.toml`(上面加几行、换个模型),下面早就报过的远端 MCP 服务器 + /// 整体挪了位置:**不再报一遍**。真的新加了一台,才报那一台 + #[tokio::test] + async fn editing_codex_config_does_not_report_known_mcp_servers_again() { + let home = tempfile::tempdir().unwrap(); + let cfg = home.path().join(".codex/config.toml"); + std::fs::create_dir_all(cfg.parent().unwrap()).unwrap(); + let docs = "[mcp_servers.docs]\nurl = \"https://mcp.example.com/sse\"\n"; + std::fs::write(&cfg, format!("model = \"gpt-5\"\n\n{docs}")).unwrap(); + let (_w, mut rx) = watching(home.path()); + tokio::time::sleep(Duration::from_millis(400)).await; + + // 上面多了几行:服务器还是那一台,只是挪了位置 + std::fs::write( + &cfg, + format!("model = \"gpt-5-codex\"\nmodel_reasoning_effort = \"high\"\n\n[tools]\nweb_search = true\n\n{docs}"), + ) + .unwrap(); + assert!(matches!( + next_event(&mut rx, "文件变了的那一声").await, + wire::LocalEvent::ClientsChanged { .. } + )); + let quiet = tokio::time::timeout(Duration::from_millis(1500), async { + loop { + if let Some(wire::LocalEvent::ScanAlert { alerts, .. }) = rx.recv().await { + return alerts; + } + } + }) + .await; + assert!(quiet.is_err(), "挪了位置也报了一遍:{:#?}", quiet.ok()); + + // 真的新加了一台:只报这一台 + std::fs::write( + &cfg, + format!("model = \"gpt-5-codex\"\n\n{docs}\n[mcp_servers.search]\nurl = \"https://search.example.org/mcp\"\n"), + ) + .unwrap(); + let alerts = next_alert(&mut rx).await; + assert_eq!(alerts.len(), 1, "{alerts:#?}"); + assert_eq!(alerts[0].rule, "remote-mcp"); + } + /// 写死的那条纪律:只报告,不自动删除。 #[tokio::test] async fn the_watcher_never_touches_a_file() {