use kedge_core::{classify, ToolSafety};
use serde::Deserialize;
const CATALOGUES: &[(&str, &str)] = &[
(
"aws-kb-retrieval",
include_str!("../catalogues/aws-kb-retrieval.json"),
),
("context7", include_str!("../catalogues/context7.json")),
("everything", include_str!("../catalogues/everything.json")),
("filesystem", include_str!("../catalogues/filesystem.json")),
("github", include_str!("../catalogues/github.json")),
("memory", include_str!("../catalogues/memory.json")),
("playwright", include_str!("../catalogues/playwright.json")),
("postgres", include_str!("../catalogues/postgres.json")),
("puppeteer", include_str!("../catalogues/puppeteer.json")),
(
"sequential-thinking",
include_str!("../catalogues/sequential-thinking.json"),
),
];
#[derive(Deserialize)]
struct Catalogue {
slug: String,
#[serde(rename = "serverInfo")]
server_info: Option<ServerInfo>,
tools: Vec<Tool>,
}
#[derive(Deserialize)]
struct ServerInfo {
name: String,
#[serde(default)]
version: Option<String>,
}
#[derive(Deserialize)]
struct Tool {
name: String,
annotations: Option<Annotations>,
}
#[derive(Deserialize)]
struct Annotations {
#[serde(rename = "readOnlyHint")]
read_only_hint: Option<bool>,
}
pub struct ServerScore {
pub slug: String,
pub server: String,
pub tools: usize,
pub scored: usize,
pub agreed: usize,
pub false_negatives: Vec<String>,
pub false_positives: Vec<String>,
}
pub struct Report {
pub servers: Vec<ServerScore>,
}
impl Report {
pub fn tools(&self) -> usize {
self.servers.iter().map(|s| s.tools).sum()
}
pub fn scored(&self) -> usize {
self.servers.iter().map(|s| s.scored).sum()
}
pub fn agreed(&self) -> usize {
self.servers.iter().map(|s| s.agreed).sum()
}
pub fn false_negatives(&self) -> usize {
self.servers.iter().map(|s| s.false_negatives.len()).sum()
}
pub fn false_positives(&self) -> usize {
self.servers.iter().map(|s| s.false_positives.len()).sum()
}
pub fn agreement_pct(&self) -> f64 {
if self.scored() == 0 {
return 0.0;
}
self.agreed() as f64 * 100.0 / self.scored() as f64
}
}
pub fn score() -> Report {
let mut servers = Vec::new();
for (slug, raw) in CATALOGUES {
let cat: Catalogue = serde_json::from_str(raw)
.unwrap_or_else(|e| panic!("catalogue {slug} is malformed: {e}"));
let mut s = ServerScore {
slug: cat.slug.clone(),
server: cat
.server_info
.as_ref()
.map(|i| match &i.version {
Some(v) => format!("{} {}", i.name, v),
None => i.name.clone(),
})
.unwrap_or_else(|| "(unnamed)".into()),
tools: cat.tools.len(),
scored: 0,
agreed: 0,
false_negatives: Vec::new(),
false_positives: Vec::new(),
};
for t in &cat.tools {
let Some(declared_read_only) = t.annotations.as_ref().and_then(|a| a.read_only_hint)
else {
continue; };
s.scored += 1;
let ours_read_only = matches!(classify(&t.name), ToolSafety::ReadOnly);
match (declared_read_only, ours_read_only) {
(a, b) if a == b => s.agreed += 1,
(false, true) => s.false_negatives.push(t.name.clone()),
(true, false) => s.false_positives.push(t.name.clone()),
_ => unreachable!(),
}
}
servers.push(s);
}
servers.sort_by(|a, b| a.slug.cmp(&b.slug));
Report { servers }
}
pub fn render(r: &Report) -> String {
let mut out = String::from("foreguard vs declared readOnlyHint\n\n");
out.push_str("server tools scored agree FN FP\n");
out.push_str("──────────────────────────────────────────────────────\n");
for s in &r.servers {
out.push_str(&format!(
" {:<20} {:>5} {:>7} {:>6} {:>4} {:>4}\n",
s.slug,
s.tools,
s.scored,
s.agreed,
s.false_negatives.len(),
s.false_positives.len(),
));
}
out.push_str(&format!(
"\n {:<20} {:>5} {:>7} {:>6} {:>4} {:>4}\n",
"TOTAL",
r.tools(),
r.scored(),
r.agreed(),
r.false_negatives(),
r.false_positives(),
));
out.push_str(&format!(
"\nagreement {:.1}% of {} scoreable tools ({} of {} carry no readOnlyHint\nand are excluded rather than assumed)\n",
r.agreement_pct(),
r.scored(),
r.tools() - r.scored(),
r.tools(),
));
if r.false_negatives() == 0 {
out.push_str(
"\nfalse negatives: 0. No tool a server calls mutating was judged read-only.\n",
);
} else {
out.push_str("\nFALSE NEGATIVES (a mutation would have been forwarded unpreviewed):\n");
for s in &r.servers {
for n in &s.false_negatives {
out.push_str(&format!(" {}::{}\n", s.slug, n));
}
}
}
if r.false_positives() > 0 {
out.push_str(&format!(
"\nfalse positives ({}): stricter than the server asked for, which is the\nsafe direction for a deny-wins classifier. Each costs a confirmation, not a\nmissed mutation.\n",
r.false_positives()
));
for s in &r.servers {
for n in &s.false_positives {
out.push_str(&format!(" {}::{}\n", s.slug, n));
}
}
}
out.push_str("\ncaptured from (server name and version as each reported it):\n");
for s in &r.servers {
out.push_str(&format!(" {:<20} {}\n", s.slug, s.server));
}
out.push_str(
"\nThe hint is the server author's opinion, not ground truth: a mislabelled tool\n\
moves this number without anything changing here. Classification is by name\n\
only, because a catalogue has no arguments to inspect, so this measures the\n\
weaker half of what runs at proxy time.\n",
);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_catalogue_loads_and_has_tools() {
for (slug, raw) in CATALOGUES {
let c: Catalogue = serde_json::from_str(raw).unwrap_or_else(|e| panic!("{slug}: {e}"));
assert!(!c.tools.is_empty(), "{slug} has no tools");
assert_eq!(&c.slug, slug, "catalogue slug does not match its filename");
}
}
#[test]
fn the_corpus_is_the_size_it_is_published_as() {
let r = score();
assert_eq!(r.servers.len(), 10, "server count changed");
assert_eq!(r.tools(), 98, "tool count changed");
assert_eq!(r.scored(), 63, "scoreable tool count changed");
}
#[test]
fn no_mutating_tool_is_judged_read_only() {
let r = score();
assert_eq!(
r.false_negatives(),
0,
"a mutating tool was classified read-only:\n{}",
render(&r)
);
}
}