Skip to main content

av_sandbox/
rpc.rs

1//! JSON-RPC 2.0 / MCP `tools/call` parsing. Total: never panics on arbitrary
2//! bytes (property-tested), returns typed errors for every malformed shape.
3
4use serde_json::Value;
5
6/// A parsed MCP tool-call request.
7#[derive(Debug, Clone, PartialEq)]
8pub struct ToolCallRequest {
9    /// JSON-RPC id (absent for notifications).
10    pub id: Option<Value>,
11    /// Tool name (`params.name`).
12    pub tool: String,
13    /// Tool arguments (`params.arguments`, defaults to `{}`).
14    pub arguments: Value,
15}
16
17/// Parse failures.
18#[derive(Debug, thiserror::Error, PartialEq, Eq)]
19#[non_exhaustive]
20pub enum RpcError {
21    /// Not valid JSON at all.
22    #[error("invalid JSON: {0}")]
23    Json(String),
24    /// Valid JSON but not a JSON-RPC 2.0 object.
25    #[error("not a JSON-RPC 2.0 request: {0}")]
26    NotJsonRpc(String),
27    /// A method other than `tools/call` (passthrough, not an error verdict β€”
28    /// the caller decides what to do with non-tool traffic).
29    #[error("method {0:?} is not tools/call")]
30    NotToolCall(String),
31    /// `tools/call` missing/invalid params.
32    #[error("invalid tools/call params: {0}")]
33    BadParams(String),
34    /// Payload exceeds the configured size bound (DoS guard).
35    #[error("payload of {0} bytes exceeds the {1}-byte bound")]
36    TooLarge(usize, usize),
37}
38
39/// Hard byte bound applied before parsing (attacker-controlled input).
40pub const MAX_PAYLOAD_BYTES: usize = 4 * 1024 * 1024;
41
42/// Depth bound applied post-parse (deeply nested JSON as a DoS vector).
43pub const MAX_JSON_DEPTH: usize = 64;
44
45/// Parse raw bytes as an MCP `tools/call`.
46pub fn parse_tool_call(raw: &[u8]) -> Result<ToolCallRequest, RpcError> {
47    if raw.len() > MAX_PAYLOAD_BYTES {
48        return Err(RpcError::TooLarge(raw.len(), MAX_PAYLOAD_BYTES));
49    }
50    let v: Value = serde_json::from_slice(raw).map_err(|e| RpcError::Json(e.to_string()))?;
51    if depth_of(&v, 0) > MAX_JSON_DEPTH {
52        return Err(RpcError::NotJsonRpc("nesting exceeds depth bound".into()));
53    }
54    let obj = v
55        .as_object()
56        .ok_or_else(|| RpcError::NotJsonRpc("root is not an object".into()))?;
57    match obj.get("jsonrpc").and_then(Value::as_str) {
58        Some("2.0") => {}
59        other => {
60            return Err(RpcError::NotJsonRpc(format!(
61                "jsonrpc field is {other:?}, need \"2.0\""
62            )))
63        }
64    }
65    let method = obj
66        .get("method")
67        .and_then(Value::as_str)
68        .ok_or_else(|| RpcError::NotJsonRpc("method missing or not a string".into()))?;
69    if method != "tools/call" {
70        return Err(RpcError::NotToolCall(method.to_owned()));
71    }
72    let params = obj
73        .get("params")
74        .and_then(Value::as_object)
75        .ok_or_else(|| RpcError::BadParams("params missing or not an object".into()))?;
76    let tool = params
77        .get("name")
78        .and_then(Value::as_str)
79        .filter(|s| !s.is_empty())
80        .ok_or_else(|| RpcError::BadParams("params.name missing or empty".into()))?;
81    if av_core::text::contains_bidi_or_zero_width(tool) {
82        return Err(RpcError::BadParams(
83            "params.name carries a bidi/zero-width spoofing character".into(),
84        ));
85    }
86    // Reject ASCII control characters and inner whitespace: without this a
87    // request for `"db_write\n"` would slip past exact-string matchers for
88    // per-tool schemas, per-tool policy deny-lists, per-tool budgets, and
89    // the harness consequential-tools workflow veto, then be normalized by
90    // most downstream MCP servers to `"db_write"`. Also cover the same
91    // trailing/leading whitespace shape.
92    if tool.chars().any(|c| c.is_control() || c.is_whitespace()) {
93        return Err(RpcError::BadParams(
94            "params.name contains a control character or whitespace".into(),
95        ));
96    }
97    // Refuse any non-ASCII byte in the tool name. Every downstream
98    // comparison β€” per-tool schema lookup, deny-list matching, per-tool
99    // budget key β€” is raw byte-equality. A caller supplying
100    // `de\u{0301}lete` (decomposed) or `𝐝𝐛_𝐰𝐫𝐒𝐭𝐞` (mathematical
101    // bold, NFKC-folds to `db_write`) sees a different byte string
102    // than a policy that lists `delete` / `db_write`, but most MCP
103    // servers apply NFC/NFKC before dispatch β€” so the visually
104    // "different" name resolves to the same tool downstream.
105    // Restricting to ASCII collapses the Unicode-normalization attack
106    // surface without adding a runtime `unicode-normalization`
107    // dependency to the parse gate.
108    if !tool.is_ascii() {
109        return Err(RpcError::BadParams(
110            "params.name must be ASCII: non-ASCII tool names introduce a Unicode-normalization mismatch \
111             between this proxy's exact-byte matching and downstream MCP servers that fold NFC/NFKC"
112                .into(),
113        ));
114    }
115    // Refuse any uppercase byte. Downstream tool-name comparisons are
116    // exact-byte, but most MCP servers apply ASCII case folding. A
117    // policy that denies `db_write` sees `DB_write` slip through the
118    // deny-list byte-eq check while the server executes the same
119    // tool. Requiring the caller to normalize to lowercase up front
120    // eliminates the class.
121    if tool.chars().any(|c| c.is_ascii_uppercase()) {
122        return Err(RpcError::BadParams(
123            "params.name must be lowercase: mixed-case tool names bypass policy deny-lists that use \
124             exact-byte matching while downstream MCP servers apply ASCII case folding"
125                .into(),
126        ));
127    }
128    // JSON-RPC 2.0 Β§4 requires `id` to be a string, number, or null: reject
129    // objects/arrays/bool up front so downstream correlation tables that key
130    // on id-as-string cannot be confused by structured ids.
131    let id = obj.get("id").cloned();
132    match id.as_ref() {
133        None => {}
134        Some(Value::String(_) | Value::Number(_) | Value::Null) => {}
135        Some(_) => {
136            return Err(RpcError::BadParams(
137                "id must be a string, number, or null per JSON-RPC 2.0 Β§4".into(),
138            ));
139        }
140    }
141    // Per JSON-RPC 2.0 Β§4.1 a request with no `id` is a notification: a
142    // notification MUST NOT elicit a response. Refusing them here keeps
143    // attackers from fire-and-forgetting `tools/call` to drain budget
144    // (max_total_tool_calls, payout) without needing to consume responses.
145    if id.is_none() {
146        return Err(RpcError::BadParams(
147            "tools/call requires an id; JSON-RPC notifications are not accepted".into(),
148        ));
149    }
150    let arguments = params
151        .get("arguments")
152        .cloned()
153        .unwrap_or_else(|| Value::Object(Default::default()));
154    if !arguments.is_object() {
155        return Err(RpcError::BadParams("params.arguments must be an object".into()));
156    }
157    Ok(ToolCallRequest {
158        id,
159        tool: tool.to_owned(),
160        arguments,
161    })
162}
163
164fn depth_of(v: &Value, current: usize) -> usize {
165    if current > MAX_JSON_DEPTH {
166        return current; // early-out: no need to recurse further
167    }
168    match v {
169        Value::Array(items) => items
170            .iter()
171            .map(|i| depth_of(i, current + 1))
172            .max()
173            .unwrap_or(current + 1),
174        Value::Object(map) => map
175            .values()
176            .map(|i| depth_of(i, current + 1))
177            .max()
178            .unwrap_or(current + 1),
179        _ => current,
180    }
181}
182
183/// Build the JSON-RPC error response for a blocked call (the "immediate
184/// authorization error back to the agent loop" from the brief).
185pub fn authorization_error(id: Option<&Value>, reason: &str) -> Value {
186    serde_json::json!({
187        "jsonrpc": "2.0",
188        "id": id.cloned().unwrap_or(Value::Null),
189        "error": {
190            "code": -32001,
191            "message": "tool call blocked by AgentVisor AI policy",
192            "data": { "reason": reason }
193        }
194    })
195}
196
197#[cfg(test)]
198mod tests {
199    #![allow(
200        clippy::unwrap_used,
201        clippy::expect_used,
202        clippy::panic,
203        clippy::indexing_slicing
204    )]
205
206    use super::*;
207    use proptest::prelude::*;
208    use serde_json::json;
209
210    fn call(tool: &str, args: Value) -> Vec<u8> {
211        serde_json::to_vec(&json!({
212            "jsonrpc": "2.0",
213            "id": 7,
214            "method": "tools/call",
215            "params": { "name": tool, "arguments": args }
216        }))
217        .unwrap()
218    }
219
220    #[test]
221    fn parses_valid_call() {
222        let req = parse_tool_call(&call("db_write", json!({"table": "users"}))).unwrap();
223        assert_eq!(req.tool, "db_write");
224        assert_eq!(req.arguments["table"], "users");
225        assert_eq!(req.id, Some(json!(7)));
226    }
227
228    #[test]
229    fn missing_arguments_defaults_to_empty_object() {
230        let raw = serde_json::to_vec(&json!({
231            "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "t"}
232        }))
233        .unwrap();
234        assert_eq!(parse_tool_call(&raw).unwrap().arguments, json!({}));
235    }
236
237    #[test]
238    fn rejects_malformed_shapes() {
239        assert!(matches!(parse_tool_call(b"not json"), Err(RpcError::Json(_))));
240        assert!(matches!(
241            parse_tool_call(b"[1,2,3]"),
242            Err(RpcError::NotJsonRpc(_))
243        ));
244        assert!(matches!(
245            parse_tool_call(br#"{"jsonrpc":"1.0","method":"tools/call"}"#),
246            Err(RpcError::NotJsonRpc(_))
247        ));
248        assert!(matches!(
249            parse_tool_call(br#"{"jsonrpc":"2.0"}"#),
250            Err(RpcError::NotJsonRpc(_))
251        ));
252        assert!(matches!(
253            parse_tool_call(br#"{"jsonrpc":"2.0","method":"resources/read"}"#),
254            Err(RpcError::NotToolCall(_))
255        ));
256        assert!(matches!(
257            parse_tool_call(br#"{"jsonrpc":"2.0","method":"tools/call"}"#),
258            Err(RpcError::BadParams(_))
259        ));
260        assert!(matches!(
261            parse_tool_call(br#"{"jsonrpc":"2.0","method":"tools/call","params":{"name":""}}"#),
262            Err(RpcError::BadParams(_))
263        ));
264        assert!(matches!(
265            parse_tool_call(
266                br#"{"jsonrpc":"2.0","method":"tools/call","params":{"name":"t","arguments":[1]}}"#
267            ),
268            Err(RpcError::BadParams(_))
269        ));
270    }
271
272    #[test]
273    fn oversized_payload_rejected_before_parse() {
274        let huge = vec![b'x'; MAX_PAYLOAD_BYTES + 1];
275        assert!(matches!(parse_tool_call(&huge), Err(RpcError::TooLarge(..))));
276    }
277
278    #[test]
279    fn max_payload_bytes_is_pinned_at_4_mib() {
280        // The DoS-guard cap is part of the public API. A `*` -> `+` mutation
281        // in `4 * 1024 * 1024` would slip past size-relative asserts because
282        // both sides self-reference the constant; pin the absolute value.
283        assert_eq!(MAX_PAYLOAD_BYTES, 4_194_304);
284    }
285
286    #[test]
287    fn deep_nesting_rejected() {
288        let mut nested = String::from("1");
289        for _ in 0..200 {
290            nested = format!("[{nested}]");
291        }
292        let raw = format!(
293            r#"{{"jsonrpc":"2.0","method":"tools/call","params":{{"name":"t","arguments":{{"x":{nested}}}}}}}"#
294        );
295        assert!(parse_tool_call(raw.as_bytes()).is_err());
296    }
297
298    #[test]
299    fn authorization_error_shape() {
300        let e = authorization_error(Some(&json!(9)), "budget exceeded");
301        assert_eq!(e["id"], 9);
302        assert_eq!(e["error"]["code"], -32001);
303        assert_eq!(e["error"]["data"]["reason"], "budget exceeded");
304    }
305
306    proptest! {
307        #![proptest_config(ProptestConfig::with_cases(256))]
308
309        /// The parser must never panic on arbitrary bytes (D13 total-parser).
310        #[test]
311        fn never_panics(bytes in prop::collection::vec(any::<u8>(), 0..2048)) {
312            let _ = parse_tool_call(&bytes);
313        }
314
315        /// Nor on arbitrary *valid JSON* of hostile shape.
316        #[test]
317        fn never_panics_on_arbitrary_json(s in "\\PC{0,500}") {
318            let _ = parse_tool_call(s.as_bytes());
319        }
320    }
321
322    /// Tool names carrying a Trojan-Source RLO or any zero-width character
323    /// must be rejected. Otherwise a hostile agent could send a payload
324    /// whose `params.name` renders as `db_write` on an operator's terminal
325    /// while the raw bytes read as a completely different tool identifier,
326    /// spoofing audit chains and receipts.
327    #[test]
328    fn tool_name_carrying_a_bidi_or_zero_width_character_is_rejected() {
329        for spoof in [
330            "db_write\u{202E}etirw_bd",
331            "\u{202E}db_write",
332            "db\u{200B}_write",
333            "db_write\u{200E}",
334            "db_write\u{2066}suffix",
335            "db_write\u{FEFF}",
336        ] {
337            let raw = call(spoof, json!({}));
338            match parse_tool_call(&raw) {
339                Err(RpcError::BadParams(reason)) => {
340                    assert!(reason.contains("spoofing"), "wrong reason: {reason}");
341                }
342                other => panic!("must reject {spoof:?}, got {other:?}"),
343            }
344        }
345    }
346
347    /// A tool name carrying ANY ASCII control character or whitespace must
348    /// be rejected. Otherwise `"db_write\n"` slips past exact-string matchers
349    /// for per-tool schemas, per-tool policy deny-lists, per-tool budget
350    /// caps, AND the harness consequential-tools workflow veto β€” and most
351    /// downstream MCP servers then normalize whitespace back to `"db_write"`,
352    /// completing the bypass.
353    #[test]
354    fn tool_name_with_control_char_or_whitespace_is_rejected() {
355        for hostile in [
356            "db_write\n",
357            "db_write\r",
358            "db_write\t",
359            "db_write\0",
360            "db_write ",
361            " db_write",
362            "db write",
363            "db_write\u{000B}",
364            "db_write\x7f",
365        ] {
366            let raw = call(hostile, json!({}));
367            match parse_tool_call(&raw) {
368                Err(RpcError::BadParams(reason)) => {
369                    assert!(
370                        reason.contains("control character or whitespace"),
371                        "wrong reason: {reason}",
372                    );
373                }
374                other => panic!("must reject {hostile:?}, got {other:?}"),
375            }
376        }
377    }
378
379    /// `tools/call` without an id is a JSON-RPC 2.0 notification. Accepting
380    /// one would let an attacker fire-and-forget consequential calls to drain
381    /// `max_total_tool_calls` and payout budget with no response to consume.
382    /// Notifications must be refused at the parse gate.
383    #[test]
384    fn tools_call_without_id_is_rejected_as_a_notification() {
385        let raw = serde_json::to_vec(&json!({
386            "jsonrpc": "2.0",
387            "method": "tools/call",
388            "params": {"name": "safe_tool", "arguments": {}}
389        }))
390        .unwrap();
391        match parse_tool_call(&raw) {
392            Err(RpcError::BadParams(reason)) => {
393                assert!(reason.contains("notification"), "wrong reason: {reason}");
394            }
395            other => panic!("notification must be rejected, got {other:?}"),
396        }
397    }
398
399    /// JSON-RPC 2.0 Β§4 restricts `id` to a String, Number, or Null.
400    /// Downstream correlation tables that key on id-as-string cannot survive
401    /// a structured id.
402    #[test]
403    fn tools_call_with_structured_id_is_rejected() {
404        for hostile_id in [
405            json!({"nested": true}),
406            json!([1, 2, 3]),
407            json!(true),
408            json!(false),
409        ] {
410            let raw = serde_json::to_vec(&json!({
411                "jsonrpc": "2.0",
412                "id": hostile_id,
413                "method": "tools/call",
414                "params": {"name": "safe_tool", "arguments": {}}
415            }))
416            .unwrap();
417            match parse_tool_call(&raw) {
418                Err(RpcError::BadParams(reason)) => {
419                    assert!(reason.contains("JSON-RPC 2.0"), "wrong reason: {reason}");
420                }
421                other => panic!("hostile id {hostile_id:?} must be rejected, got {other:?}"),
422            }
423        }
424    }
425
426    /// String, number, and null ids are all accepted (Β§4 spec shape).
427    #[test]
428    fn valid_id_shapes_are_accepted() {
429        for good_id in [json!("uuid-123"), json!(42), json!(-7), json!(null)] {
430            let raw = serde_json::to_vec(&json!({
431                "jsonrpc": "2.0",
432                "id": good_id,
433                "method": "tools/call",
434                "params": {"name": "safe_tool", "arguments": {}}
435            }))
436            .unwrap();
437            let parsed = parse_tool_call(&raw).unwrap_or_else(|e| panic!("{good_id:?}: {e:?}"));
438            assert_eq!(parsed.id, Some(good_id));
439        }
440    }
441
442    /// Tool-name case-sensitivity bypass: downstream deny-list /
443    /// budget-key / schema-lookup are byte-exact, but most MCP servers
444    /// apply ASCII case folding β€” so `DB_write` would bypass a policy
445    /// that denies `db_write` while executing the same tool. The parse
446    /// gate now requires lowercase up front.
447    #[test]
448    fn tools_call_with_uppercase_letters_in_name_is_rejected() {
449        for hostile in ["DB_write", "Delete", "readFile", "PING"] {
450            let raw = serde_json::to_vec(&json!({
451                "jsonrpc": "2.0",
452                "id": "case-attack",
453                "method": "tools/call",
454                "params": {"name": hostile, "arguments": {}}
455            }))
456            .unwrap();
457            let err = parse_tool_call(&raw).unwrap_err();
458            assert!(
459                matches!(err, RpcError::BadParams(ref msg) if msg.contains("lowercase")),
460                "expected lowercase-refusal for {hostile:?}, got {err:?}"
461            );
462        }
463    }
464
465    /// Unicode-normalization bypass: `de\u{0301}lete` (decomposed) is
466    /// visually identical to `dΓ©lete` (precomposed) but has a different
467    /// byte string. NFKC-fold variants like `𝐝𝐛_𝐰𝐫𝐒𝐭𝐞` collapse to
468    /// `db_write` on the server side while the proxy sees a distinct
469    /// name. Requiring ASCII bytes collapses this attack surface.
470    #[test]
471    fn tools_call_with_non_ascii_bytes_in_name_is_rejected() {
472        for hostile in [
473            "d\u{0301}elete",
474            "de\u{0301}lete",
475            "dΓ©lete",
476            "𝐝𝐛_𝐰𝐫𝐒𝐭𝐞",
477            "read_file",
478        ] {
479            let raw = serde_json::to_vec(&json!({
480                "jsonrpc": "2.0",
481                "id": "unicode-attack",
482                "method": "tools/call",
483                "params": {"name": hostile, "arguments": {}}
484            }))
485            .unwrap();
486            let err = parse_tool_call(&raw).unwrap_err();
487            assert!(
488                matches!(err, RpcError::BadParams(ref msg) if msg.contains("ASCII")),
489                "expected ASCII-only refusal for {hostile:?}, got {err:?}"
490            );
491        }
492    }
493}
494
495#[cfg(test)]
496mod depth_boundary_tests {
497    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
498
499    use super::*;
500
501    /// Mutation-run hardening (round 11): the depth cap was only proven far
502    /// past the limit, so boundary mutants (`current + 1` -> `current * 1`,
503    /// deleting the Array arm) survived. Pin the exact boundary through the
504    /// public parser for BOTH container kinds. Depth accounting: the
505    /// envelope contributes 3 levels (root -> params -> arguments), and each
506    /// wrapper inside `arguments.k` adds one, so 61 wrappers sit exactly at
507    /// MAX_JSON_DEPTH = 64 and 62 exceed it.
508    #[test]
509    fn json_depth_boundary_is_exact_for_arrays_and_objects() {
510        let build = |inner: String| {
511            format!(
512                r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"t","arguments":{{"k":{inner}}}}}}}"#
513            )
514        };
515        let arrays = |n: usize| format!("{}0{}", "[".repeat(n), "]".repeat(n));
516        let objects = |n: usize| format!("{}0{}", "{\"x\":".repeat(n), "}".repeat(n));
517        for shape in [&arrays as &dyn Fn(usize) -> String, &objects] {
518            let at_limit = build(shape(MAX_JSON_DEPTH - 3));
519            assert!(
520                parse_tool_call(at_limit.as_bytes()).is_ok(),
521                "depth exactly at MAX_JSON_DEPTH must parse"
522            );
523            let past = build(shape(MAX_JSON_DEPTH - 2));
524            let outcome = parse_tool_call(past.as_bytes());
525            assert!(
526                matches!(outcome, Err(RpcError::NotJsonRpc(ref m)) if m.contains("depth")),
527                "one past MAX_JSON_DEPTH must be refused, got {outcome:?}"
528            );
529        }
530        // Empty containers exercise the `unwrap_or(current + 1)` fallback
531        // arms β€” an empty object/array is still one level deep, so one
532        // sitting past the cap must be refused too.
533        for leaf in ["{}", "[]"] {
534            let wrappers = "{\"x\":".repeat(MAX_JSON_DEPTH - 3);
535            let closers = "}".repeat(MAX_JSON_DEPTH - 3);
536            let past = build(format!("{wrappers}{leaf}{closers}"));
537            let outcome = parse_tool_call(past.as_bytes());
538            assert!(
539                matches!(outcome, Err(RpcError::NotJsonRpc(ref m)) if m.contains("depth")),
540                "empty {leaf} leaf past MAX_JSON_DEPTH must be refused, got {outcome:?}"
541            );
542        }
543    }
544}