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}