agentox_core/checks/behavioral/
idempotency.rs1use crate::checks::runner::{Check, CheckContext};
4use crate::checks::types::{CheckCategory, CheckResult, Severity};
5use crate::protocol::mcp_types::Tool;
6
7pub struct IdempotencyBaseline;
8
9fn fingerprint_tools(tools: &[Tool]) -> Vec<String> {
10 let mut out: Vec<String> = tools
11 .iter()
12 .map(|t| {
13 let input = serde_json::to_string(&t.input_schema).unwrap_or_else(|_| "{}".to_string());
14 let output = t
15 .output_schema
16 .as_ref()
17 .and_then(|v| serde_json::to_string(v).ok())
18 .unwrap_or_else(|| "null".to_string());
19 format!("{}|{}|{}", t.name, input, output)
20 })
21 .collect();
22 out.sort();
23 out
24}
25
26#[async_trait::async_trait]
27impl Check for IdempotencyBaseline {
28 fn id(&self) -> &str {
29 "BHV-001"
30 }
31
32 fn name(&self) -> &str {
33 "Idempotency baseline"
34 }
35
36 fn category(&self) -> CheckCategory {
37 CheckCategory::Behavioral
38 }
39
40 async fn run(&self, ctx: &mut CheckContext) -> Vec<CheckResult> {
41 let desc =
42 "Repeated tools/list calls in a single session should produce a stable tool fingerprint";
43 let first = match ctx.session.list_tools().await {
44 Ok(v) => v,
45 Err(e) => {
46 return vec![CheckResult::fail(
47 self.id(),
48 self.name(),
49 self.category(),
50 Severity::Medium,
51 desc,
52 format!("First tools/list failed: {e}"),
53 )];
54 }
55 };
56 let second = match ctx.session.list_tools().await {
57 Ok(v) => v,
58 Err(e) => {
59 return vec![CheckResult::fail(
60 self.id(),
61 self.name(),
62 self.category(),
63 Severity::Medium,
64 desc,
65 format!("Second tools/list failed: {e}"),
66 )];
67 }
68 };
69
70 let fp1 = fingerprint_tools(&first);
71 let fp2 = fingerprint_tools(&second);
72 if fp1 == fp2 {
73 vec![CheckResult::pass(
74 self.id(),
75 self.name(),
76 self.category(),
77 desc,
78 )]
79 } else {
80 let only_1: Vec<_> = fp1.iter().filter(|x| !fp2.contains(*x)).cloned().collect();
81 let only_2: Vec<_> = fp2.iter().filter(|x| !fp1.contains(*x)).cloned().collect();
82 vec![CheckResult::fail(
83 self.id(),
84 self.name(),
85 self.category(),
86 Severity::Medium,
87 desc,
88 "tools/list fingerprint changed between identical calls",
89 )
90 .with_evidence(serde_json::json!({
91 "fingerprint_1_count": fp1.len(),
92 "fingerprint_2_count": fp2.len(),
93 "only_in_first": only_1,
94 "only_in_second": only_2
95 }))]
96 }
97 }
98}