Skip to main content

agentox_core/checks/behavioral/
idempotency.rs

1//! BHV-001: tools/list should be idempotent within a session.
2
3use 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}