Skip to main content

agentox_core/checks/conformance/
jsonrpc_structure.rs

1//! CONF-002: Validates JSON-RPC 2.0 message structure on responses.
2
3use crate::checks::runner::{Check, CheckContext};
4use crate::checks::types::{CheckCategory, CheckResult, Severity};
5use crate::protocol::jsonrpc::JsonRpcRequest;
6
7pub struct JsonRpcStructure;
8
9#[async_trait::async_trait]
10impl Check for JsonRpcStructure {
11    fn id(&self) -> &str {
12        "CONF-002"
13    }
14
15    fn name(&self) -> &str {
16        "JSON-RPC 2.0 message structure"
17    }
18
19    fn category(&self) -> CheckCategory {
20        CheckCategory::Conformance
21    }
22
23    async fn run(&self, ctx: &mut CheckContext) -> Vec<CheckResult> {
24        let desc =
25            "All responses must have jsonrpc=\"2.0\", matching id, and exactly one of result/error";
26
27        // Send a simple tools/list request and inspect the raw response
28        let req = JsonRpcRequest::new(9999, "tools/list", Some(serde_json::json!({})));
29        let raw = serde_json::to_string(&req).unwrap();
30
31        match ctx.session.send_raw(&raw).await {
32            Ok(Some(response_str)) => {
33                match serde_json::from_str::<serde_json::Value>(&response_str) {
34                    Ok(val) => {
35                        let mut results = Vec::new();
36
37                        // Check jsonrpc field
38                        match val.get("jsonrpc").and_then(|v| v.as_str()) {
39                            Some("2.0") => {}
40                            Some(other) => {
41                                results.push(
42                                    CheckResult::fail(
43                                        self.id(),
44                                        self.name(),
45                                        self.category(),
46                                        Severity::High,
47                                        desc,
48                                        format!("jsonrpc field is \"{other}\" instead of \"2.0\""),
49                                    )
50                                    .with_evidence(val.clone()),
51                                );
52                            }
53                            None => {
54                                results.push(
55                                    CheckResult::fail(
56                                        self.id(),
57                                        self.name(),
58                                        self.category(),
59                                        Severity::High,
60                                        desc,
61                                        "jsonrpc field is missing from response",
62                                    )
63                                    .with_evidence(val.clone()),
64                                );
65                            }
66                        }
67
68                        // Check id matches
69                        match val.get("id") {
70                            Some(id) if id.as_i64() == Some(9999) => {}
71                            Some(id) => {
72                                results.push(
73                                    CheckResult::fail(
74                                        self.id(),
75                                        self.name(),
76                                        self.category(),
77                                        Severity::High,
78                                        desc,
79                                        format!(
80                                            "Response id ({id}) does not match request id (9999)"
81                                        ),
82                                    )
83                                    .with_evidence(val.clone()),
84                                );
85                            }
86                            None => {
87                                results.push(
88                                    CheckResult::fail(
89                                        self.id(),
90                                        self.name(),
91                                        self.category(),
92                                        Severity::High,
93                                        desc,
94                                        "Response is missing id field",
95                                    )
96                                    .with_evidence(val.clone()),
97                                );
98                            }
99                        }
100
101                        // Check exactly one of result/error
102                        let has_result = val.get("result").is_some();
103                        let has_error = val.get("error").is_some();
104                        if has_result && has_error {
105                            results.push(CheckResult::fail(
106                                self.id(),
107                                self.name(),
108                                self.category(),
109                                Severity::High,
110                                desc,
111                                "Response has both result and error (must have exactly one)",
112                            ));
113                        } else if !has_result && !has_error {
114                            results.push(CheckResult::fail(
115                                self.id(),
116                                self.name(),
117                                self.category(),
118                                Severity::High,
119                                desc,
120                                "Response has neither result nor error (must have exactly one)",
121                            ));
122                        }
123
124                        if results.is_empty() {
125                            results.push(CheckResult::pass(
126                                self.id(),
127                                self.name(),
128                                self.category(),
129                                desc,
130                            ));
131                        }
132
133                        results
134                    }
135                    Err(e) => {
136                        vec![CheckResult::fail(
137                            self.id(),
138                            self.name(),
139                            self.category(),
140                            Severity::High,
141                            desc,
142                            format!("Response is not valid JSON: {e}"),
143                        )]
144                    }
145                }
146            }
147            Ok(None) => {
148                vec![CheckResult::fail(
149                    self.id(),
150                    self.name(),
151                    self.category(),
152                    Severity::High,
153                    desc,
154                    "No response received from server",
155                )]
156            }
157            Err(e) => {
158                vec![CheckResult::fail(
159                    self.id(),
160                    self.name(),
161                    self.category(),
162                    Severity::High,
163                    desc,
164                    format!("Transport error: {e}"),
165                )]
166            }
167        }
168    }
169}