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 crate::denial_code::DenialCode;
5use serde_json::Value;
6
7/// A parsed MCP tool-call request.
8#[derive(Debug, Clone, PartialEq)]
9pub struct ToolCallRequest {
10    /// JSON-RPC id (absent for notifications).
11    pub id: Option<Value>,
12    /// Tool name (`params.name`).
13    pub tool: String,
14    /// Tool arguments (`params.arguments`, defaults to `{}`).
15    pub arguments: Value,
16}
17
18/// Parse failures.
19#[derive(Debug, thiserror::Error, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum RpcError {
22    /// Not valid JSON at all.
23    #[error("invalid JSON: {0}")]
24    Json(String),
25    /// Valid JSON but not a JSON-RPC 2.0 object.
26    #[error("not a JSON-RPC 2.0 request: {0}")]
27    NotJsonRpc(String),
28    /// A method other than `tools/call` (passthrough, not an error verdict —
29    /// the caller decides what to do with non-tool traffic).
30    #[error("method {0:?} is not tools/call")]
31    NotToolCall(String),
32    /// `tools/call` missing/invalid params.
33    #[error("invalid tools/call params: {0}")]
34    BadParams(String),
35    /// Payload exceeds the configured size bound (DoS guard).
36    #[error("payload of {0} bytes exceeds the {1}-byte bound")]
37    TooLarge(usize, usize),
38}
39
40/// Hard byte bound applied before parsing (attacker-controlled input).
41pub const MAX_PAYLOAD_BYTES: usize = 4 * 1024 * 1024;
42
43/// Depth bound applied post-parse (deeply nested JSON as a DoS vector).
44pub const MAX_JSON_DEPTH: usize = 64;
45
46/// Parse raw bytes as an MCP `tools/call`.
47pub fn parse_tool_call(raw: &[u8]) -> Result<ToolCallRequest, RpcError> {
48    if raw.len() > MAX_PAYLOAD_BYTES {
49        return Err(RpcError::TooLarge(raw.len(), MAX_PAYLOAD_BYTES));
50    }
51    // Duplicate-key rejection: `serde_json` silently keeps the LAST value
52    // on duplicate keys, but the raw body is forwarded unchanged to the
53    // configured tool upstream. If the upstream's JSON decoder is
54    // first-wins (jackson, some Go decoders) or exposes both values, a
55    // request like
56    //   {"params":{"name":"safe_read","name":"db_write","arguments":{}}}
57    // parses here as `tool = "db_write"` (last-wins), passes the
58    // safe_read policy/budget gate, then executes `db_write` upstream —
59    // a permissions-model split with full audit-trail mismatch. Reject
60    // duplicates so gate and upstream see the same request or neither
61    // does. RFC 8259 §4 makes duplicate-name handling "implementation-
62    // defined", so the trust-boundary policy is: refuse ambiguity.
63    reject_duplicate_keys(raw)?;
64    let v: Value = serde_json::from_slice(raw).map_err(|e| RpcError::Json(e.to_string()))?;
65    if depth_of(&v, 0) > MAX_JSON_DEPTH {
66        return Err(RpcError::NotJsonRpc("nesting exceeds depth bound".into()));
67    }
68    let obj = v
69        .as_object()
70        .ok_or_else(|| RpcError::NotJsonRpc("root is not an object".into()))?;
71    match obj.get("jsonrpc").and_then(Value::as_str) {
72        Some("2.0") => {}
73        other => {
74            return Err(RpcError::NotJsonRpc(format!(
75                "jsonrpc field is {other:?}, need \"2.0\""
76            )))
77        }
78    }
79    let method = obj
80        .get("method")
81        .and_then(Value::as_str)
82        .ok_or_else(|| RpcError::NotJsonRpc("method missing or not a string".into()))?;
83    if method != "tools/call" {
84        return Err(RpcError::NotToolCall(method.to_owned()));
85    }
86    let params = obj
87        .get("params")
88        .and_then(Value::as_object)
89        .ok_or_else(|| RpcError::BadParams("params missing or not an object".into()))?;
90    let tool = params
91        .get("name")
92        .and_then(Value::as_str)
93        .filter(|s| !s.is_empty())
94        .ok_or_else(|| RpcError::BadParams("params.name missing or empty".into()))?;
95    if av_core::text::contains_bidi_or_zero_width(tool) {
96        return Err(RpcError::BadParams(
97            "params.name carries a bidi/zero-width spoofing character".into(),
98        ));
99    }
100    // Reject ASCII control characters and inner whitespace: without this a
101    // request for `"db_write\n"` would slip past exact-string matchers for
102    // per-tool schemas, per-tool policy deny-lists, per-tool budgets, and
103    // the harness consequential-tools workflow veto, then be normalized by
104    // most downstream MCP servers to `"db_write"`. Also cover the same
105    // trailing/leading whitespace shape.
106    if tool.chars().any(|c| c.is_control() || c.is_whitespace()) {
107        return Err(RpcError::BadParams(
108            "params.name contains a control character or whitespace".into(),
109        ));
110    }
111    // Refuse any non-ASCII byte in the tool name. Every downstream
112    // comparison — per-tool schema lookup, deny-list matching, per-tool
113    // budget key — is raw byte-equality. A caller supplying
114    // `de\u{0301}lete` (decomposed) or `𝐝𝐛_𝐰𝐫𝐢𝐭𝐞` (mathematical
115    // bold, NFKC-folds to `db_write`) sees a different byte string
116    // than a policy that lists `delete` / `db_write`, but most MCP
117    // servers apply NFC/NFKC before dispatch — so the visually
118    // "different" name resolves to the same tool downstream.
119    // Restricting to ASCII collapses the Unicode-normalization attack
120    // surface without adding a runtime `unicode-normalization`
121    // dependency to the parse gate.
122    if !tool.is_ascii() {
123        return Err(RpcError::BadParams(
124            "params.name must be ASCII: non-ASCII tool names introduce a Unicode-normalization mismatch \
125             between this proxy's exact-byte matching and downstream MCP servers that fold NFC/NFKC"
126                .into(),
127        ));
128    }
129    // Refuse any uppercase byte. Downstream tool-name comparisons are
130    // exact-byte, but most MCP servers apply ASCII case folding. A
131    // policy that denies `db_write` sees `DB_write` slip through the
132    // deny-list byte-eq check while the server executes the same
133    // tool. Requiring the caller to normalize to lowercase up front
134    // eliminates the class.
135    if tool.chars().any(|c| c.is_ascii_uppercase()) {
136        return Err(RpcError::BadParams(
137            "params.name must be lowercase: mixed-case tool names bypass policy deny-lists that use \
138             exact-byte matching while downstream MCP servers apply ASCII case folding"
139                .into(),
140        ));
141    }
142    // JSON-RPC 2.0 §4 requires `id` to be a string, number, or null: reject
143    // objects/arrays/bool up front so downstream correlation tables that key
144    // on id-as-string cannot be confused by structured ids.
145    let id = obj.get("id").cloned();
146    match id.as_ref() {
147        None => {}
148        Some(Value::String(_) | Value::Number(_) | Value::Null) => {}
149        Some(_) => {
150            return Err(RpcError::BadParams(
151                "id must be a string, number, or null per JSON-RPC 2.0 §4".into(),
152            ));
153        }
154    }
155    // Per JSON-RPC 2.0 §4.1 a request with no `id` is a notification: a
156    // notification MUST NOT elicit a response. Refusing them here keeps
157    // attackers from fire-and-forgetting `tools/call` to drain budget
158    // (max_total_tool_calls, payout) without needing to consume responses.
159    if id.is_none() {
160        return Err(RpcError::BadParams(
161            "tools/call requires an id; JSON-RPC notifications are not accepted".into(),
162        ));
163    }
164    let arguments = params
165        .get("arguments")
166        .cloned()
167        .unwrap_or_else(|| Value::Object(Default::default()));
168    if !arguments.is_object() {
169        return Err(RpcError::BadParams("params.arguments must be an object".into()));
170    }
171    Ok(ToolCallRequest {
172        id,
173        tool: tool.to_owned(),
174        arguments,
175    })
176}
177
178/// Compute the maximum nesting depth of a JSON value. Exposed so
179/// callers outside this crate (chat-completions ingress and any
180/// admin body parser) can apply the same trust-boundary
181/// depth cap without duplicating logic.
182pub fn depth_of(v: &Value, current: usize) -> usize {
183    if current > MAX_JSON_DEPTH {
184        return current; // early-out: no need to recurse further
185    }
186    match v {
187        Value::Array(items) => items
188            .iter()
189            .map(|i| depth_of(i, current + 1))
190            .max()
191            .unwrap_or(current + 1),
192        Value::Object(map) => map
193            .values()
194            .map(|i| depth_of(i, current + 1))
195            .max()
196            .unwrap_or(current + 1),
197        _ => current,
198    }
199}
200
201/// The general-purpose duplicate-key
202/// rejection primitive, exposed for callers outside this crate (chat
203/// completions ingress, admin body parsers) that want the same
204/// trust-boundary "refuse ambiguous JSON" semantics without pulling in
205/// `RpcError`.
206///
207/// Returns `Ok(())` if `raw` contains no duplicate keys at any nesting
208/// level; otherwise a description of the offending key.
209pub fn refuse_duplicate_json_keys(raw: &[u8]) -> Result<(), String> {
210    reject_duplicate_keys(raw).map_err(|error| error.to_string())
211}
212
213/// Refuse any JSON object with duplicate keys anywhere in the payload.
214/// `serde_json` silently keeps the last value; downstream MCP servers may
215/// keep the first, expose both, or interpret the collapse differently.
216/// The trust-boundary policy is "refuse ambiguity" — see the call-site
217/// comment in `parse_tool_call`.
218///
219/// Distinguish scanner-created "duplicate key"
220/// errors from underlying `serde_json` parse errors (EOF, unbalanced
221/// braces, invalid escape, recursion limit). The prior blanket
222/// `.map_err(|e| RpcError::Json(format!("duplicate JSON key rejected:
223/// {e}")))` gave EVERY scanner error the same misleading
224/// "duplicate JSON key rejected" prefix, so operator triage on
225/// dup-key alerts fired on any malformed JSON. Mirrors the
226/// sentinel fix in `av_receipts::check_no_duplicate_keys`.
227///
228/// Also refuse trailing garbage AFTER a
229/// valid JSON value. `deserialize_any` returns after the first
230/// complete value, so an input like `{"ok":1}garbage` was silently
231/// accepted. `Deserializer::end()` returns Err if any non-whitespace
232/// bytes remain, closing the class of "smuggled second document"
233/// attacks (JSON body-splitting through a proxy that only inspected
234/// the first value).
235fn reject_duplicate_keys(raw: &[u8]) -> Result<(), RpcError> {
236    use serde::de::{Deserializer, MapAccess, SeqAccess, Visitor};
237    use std::fmt;
238
239    const DUP_KEY_SENTINEL: &str = "__av_sandbox_dup:";
240
241    struct NoDupKeys;
242
243    impl<'de> Visitor<'de> for NoDupKeys {
244        type Value = ();
245
246        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247            f.write_str("any JSON value without duplicate object keys")
248        }
249
250        fn visit_bool<E>(self, _: bool) -> Result<(), E> {
251            Ok(())
252        }
253        fn visit_i64<E>(self, _: i64) -> Result<(), E> {
254            Ok(())
255        }
256        fn visit_u64<E>(self, _: u64) -> Result<(), E> {
257            Ok(())
258        }
259        fn visit_f64<E>(self, _: f64) -> Result<(), E> {
260            Ok(())
261        }
262        fn visit_str<E>(self, _: &str) -> Result<(), E> {
263            Ok(())
264        }
265        fn visit_string<E>(self, _: String) -> Result<(), E> {
266            Ok(())
267        }
268        fn visit_none<E>(self) -> Result<(), E> {
269            Ok(())
270        }
271        fn visit_unit<E>(self) -> Result<(), E> {
272            Ok(())
273        }
274
275        fn visit_some<D>(self, deserializer: D) -> Result<(), D::Error>
276        where
277            D: Deserializer<'de>,
278        {
279            deserializer.deserialize_any(NoDupKeys)
280        }
281
282        fn visit_seq<A>(self, mut seq: A) -> Result<(), A::Error>
283        where
284            A: SeqAccess<'de>,
285        {
286            while seq.next_element::<NoDupWrap>()?.is_some() {}
287            Ok(())
288        }
289
290        fn visit_map<A>(self, mut map: A) -> Result<(), A::Error>
291        where
292            A: MapAccess<'de>,
293        {
294            let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
295            while let Some(key) = map.next_key::<String>()? {
296                if !seen.insert(key.clone()) {
297                    return Err(serde::de::Error::custom(format!(
298                        "{DUP_KEY_SENTINEL}duplicate object key `{}`",
299                        key.escape_debug()
300                    )));
301                }
302                let _: NoDupWrap = map.next_value()?;
303            }
304            Ok(())
305        }
306    }
307
308    // Wrapper so nested structures use the same visitor.
309    struct NoDupWrap;
310    impl<'de> serde::Deserialize<'de> for NoDupWrap {
311        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
312        where
313            D: Deserializer<'de>,
314        {
315            deserializer.deserialize_any(NoDupKeys)?;
316            Ok(NoDupWrap)
317        }
318    }
319
320    let mut de = serde_json::Deserializer::from_slice(raw);
321    if let Err(error) = de.deserialize_any(NoDupKeys) {
322        let msg = error.to_string();
323        return if let Some(rest) = msg.strip_prefix(DUP_KEY_SENTINEL) {
324            Err(RpcError::Json(format!("duplicate JSON key rejected: {rest}")))
325        } else {
326            // Not a duplicate-key error — the underlying serde_json
327            // error (EOF, unbalanced braces, invalid escape,
328            // recursion limit) surfaces with its own prefix so
329            // triage doesn't misattribute the class.
330            Err(RpcError::Json(msg))
331        };
332    }
333    // Refuse trailing content after the first complete
334    // JSON value. `de.end()` returns Err on any non-whitespace
335    // trailing bytes.
336    de.end()
337        .map_err(|error| RpcError::Json(format!("trailing content after JSON value: {error}")))?;
338    Ok(())
339}
340
341/// Build the JSON-RPC error response for a blocked call (the "immediate
342/// authorization error back to the agent loop" from the brief).
343pub fn authorization_error(id: Option<&Value>, reason: &str) -> Value {
344    serde_json::json!({
345        "jsonrpc": "2.0",
346        "id": id.cloned().unwrap_or(Value::Null),
347        "error": {
348            "code": -32001,
349            "message": "tool call blocked by AgentVisor AI policy",
350            "data": { "reason": reason }
351        }
352    })
353}
354
355/// [`authorization_error`] plus the machine-readable denial code in
356/// `error.data.code`, so an agent can branch on the code without parsing
357/// the reason text.
358pub fn denial_error(id: Option<&Value>, code: DenialCode, reason: &str) -> Value {
359    serde_json::json!({
360        "jsonrpc": "2.0",
361        "id": id.cloned().unwrap_or(Value::Null),
362        "error": {
363            "code": -32001,
364            "message": "tool call blocked by AgentVisor AI policy",
365            "data": { "reason": reason, "code": code.as_str() }
366        }
367    })
368}
369
370/// Recover the request `id` for an error response, per JSON-RPC 2.0 §5:
371/// the response `id` MUST echo the request's when it is detectable, and
372/// be `null` only "if there was an error in detecting the id" (parse
373/// error / invalid request). [`RpcError::NotToolCall`] and
374/// [`RpcError::BadParams`] both mean the envelope parsed as a
375/// well-formed JSON-RPC 2.0 object, so the id IS detectable — replying
376/// `null` broke client correlation (a pending `initialize` call hung
377/// until timeout because its error response carried `id: null`).
378/// Parse-class failures (invalid JSON, duplicate keys, oversize,
379/// non-2.0 envelope) keep `null`: the id is absent, ambiguous, or
380/// untrustworthy there.
381#[must_use]
382pub fn detectable_error_id(raw: &[u8], error: &RpcError) -> Option<Value> {
383    if !matches!(error, RpcError::NotToolCall(_) | RpcError::BadParams(_)) {
384        return None;
385    }
386    // Both variants are only produced after the size, duplicate-key and
387    // depth gates passed, so this bounded re-parse cannot do more work
388    // than the original.
389    let value: Value = serde_json::from_slice(raw).ok()?;
390    let id = value.as_object()?.get("id")?;
391    // §4: id must be a string, number, or null — echo nothing else.
392    matches!(id, Value::String(_) | Value::Number(_) | Value::Null).then(|| id.clone())
393}
394
395/// JSON-RPC 2.0 reserves -32700 (parse
396/// error), -32600 (invalid request), and -32602 (invalid params) for
397/// requests that never reached application logic. Reporting those as
398/// -32001 "blocked by policy" (with HTTP 403) falsely told clients an
399/// authorization decision was made about a request that never parsed.
400pub fn protocol_error(id: Option<&Value>, error: &RpcError) -> Value {
401    let (code, message) = match error {
402        RpcError::Json(_) => (-32700, "parse error"),
403        RpcError::NotJsonRpc(_) | RpcError::TooLarge(..) => (-32600, "invalid request"),
404        RpcError::BadParams(_) => (-32602, "invalid params"),
405        // Passthrough refusal is an application-level policy choice.
406        RpcError::NotToolCall(_) => {
407            return denial_error(id, DenialCode::PassthroughRefused, &error.to_string());
408        }
409    };
410    serde_json::json!({
411        "jsonrpc": "2.0",
412        "id": id.cloned().unwrap_or(Value::Null),
413        "error": {
414            "code": code,
415            "message": message,
416            "data": { "reason": error.to_string(), "code": DenialCode::ParseError.as_str() }
417        }
418    })
419}
420
421#[cfg(test)]
422mod tests {
423    #![allow(
424        clippy::unwrap_used,
425        clippy::expect_used,
426        clippy::panic,
427        clippy::indexing_slicing
428    )]
429
430    use super::*;
431    use proptest::prelude::*;
432    use serde_json::json;
433
434    fn call(tool: &str, args: Value) -> Vec<u8> {
435        serde_json::to_vec(&json!({
436            "jsonrpc": "2.0",
437            "id": 7,
438            "method": "tools/call",
439            "params": { "name": tool, "arguments": args }
440        }))
441        .unwrap()
442    }
443
444    /// Mutation-run hardening: the public wrapper `refuse_duplicate_json_keys`
445    /// — the exact gate the harness chat ingress calls before serde ever sees
446    /// a request body — had no direct test, so a mutant replacing its body
447    /// with `Ok(())` survived this crate's suite. Pin: duplicates refused at
448    /// every nesting position, trailing garbage refused, clean bodies pass.
449    #[test]
450    fn refuse_duplicate_json_keys_covers_nesting_and_trailing_garbage() {
451        assert!(refuse_duplicate_json_keys(br#"{"a":1,"a":2}"#).is_err());
452        assert!(refuse_duplicate_json_keys(br#"{"outer":{"a":1,"a":2}}"#).is_err());
453        assert!(refuse_duplicate_json_keys(br#"{"arr":[{"a":1,"a":2}]}"#).is_err());
454        assert!(refuse_duplicate_json_keys(br#"{"ok":1}garbage"#).is_err());
455        assert!(refuse_duplicate_json_keys(br#"{"a":1,"b":{"a":1},"c":[1,"a"]}"#).is_ok());
456        assert!(refuse_duplicate_json_keys(b"[1,2,3]").is_ok());
457    }
458
459    /// Mutation-run hardening: the reserved JSON-RPC error codes are wire
460    /// contract (-32700 parse / -32600 invalid request / -32602 invalid
461    /// params); a mutant deleting the sign survived. Also pin that a
462    /// non-`tools/call` method routes to the application-level
463    /// authorization error rather than a protocol code.
464    #[test]
465    fn protocol_error_pins_reserved_jsonrpc_codes() {
466        let cases = [
467            (RpcError::Json("x".into()), -32700),
468            (RpcError::NotJsonRpc("x".into()), -32600),
469            (RpcError::TooLarge(9, 1), -32600),
470            (RpcError::BadParams("x".into()), -32602),
471        ];
472        for (error, expected) in cases {
473            let response = protocol_error(None, &error);
474            assert_eq!(response["error"]["code"].as_i64(), Some(expected), "{error:?}");
475        }
476        let passthrough = protocol_error(None, &RpcError::NotToolCall("initialize".into()));
477        assert_eq!(passthrough["error"]["code"].as_i64(), Some(-32001));
478    }
479
480    /// JSON-RPC 2.0 §5: an error response MUST echo the request `id`
481    /// whenever it is detectable, and use `null` only when detection
482    /// failed. Pre-fix every parse-gate refusal replied `id: null`,
483    /// including well-formed envelopes (`initialize` passthrough, bad
484    /// params) whose id was plainly readable — SDK correlation tables
485    /// never matched the response, so the pending call hung to timeout.
486    #[test]
487    fn error_responses_echo_a_detectable_request_id() {
488        // Well-formed envelope, non-tools/call method: id 42 detectable.
489        let raw = br#"{"jsonrpc":"2.0","id":42,"method":"initialize","params":{}}"#;
490        let error = parse_tool_call(raw).unwrap_err();
491        assert!(matches!(error, RpcError::NotToolCall(_)));
492        let id = detectable_error_id(raw, &error);
493        assert_eq!(id, Some(json!(42)));
494        let response = protocol_error(id.as_ref(), &error);
495        assert_eq!(response["id"], json!(42));
496
497        // Well-formed envelope, bad params: string id detectable.
498        let raw = br#"{"jsonrpc":"2.0","id":"req-1","method":"tools/call","params":{}}"#;
499        let error = parse_tool_call(raw).unwrap_err();
500        assert!(matches!(error, RpcError::BadParams(_)));
501        assert_eq!(detectable_error_id(raw, &error), Some(json!("req-1")));
502
503        // Structured (spec-invalid) id: refused as BadParams, and the
504        // echo must NOT propagate the invalid shape — null stands.
505        let raw = br#"{"jsonrpc":"2.0","id":{"nested":1},"method":"tools/call","params":{"name":"t"}}"#;
506        let error = parse_tool_call(raw).unwrap_err();
507        assert!(matches!(error, RpcError::BadParams(_)));
508        assert_eq!(detectable_error_id(raw, &error), None);
509
510        // Parse-class failures: id undetectable per spec — null.
511        let raw = b"not json";
512        let error = parse_tool_call(raw).unwrap_err();
513        assert_eq!(detectable_error_id(raw, &error), None);
514        let response = protocol_error(None, &error);
515        assert_eq!(response["id"], Value::Null);
516    }
517
518    #[test]
519    fn parses_valid_call() {
520        let req = parse_tool_call(&call("db_write", json!({"table": "users"}))).unwrap();
521        assert_eq!(req.tool, "db_write");
522        assert_eq!(req.arguments["table"], "users");
523        assert_eq!(req.id, Some(json!(7)));
524    }
525
526    #[test]
527    fn missing_arguments_defaults_to_empty_object() {
528        let raw = serde_json::to_vec(&json!({
529            "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "t"}
530        }))
531        .unwrap();
532        assert_eq!(parse_tool_call(&raw).unwrap().arguments, json!({}));
533    }
534
535    #[test]
536    fn rejects_malformed_shapes() {
537        assert!(matches!(parse_tool_call(b"not json"), Err(RpcError::Json(_))));
538        assert!(matches!(
539            parse_tool_call(b"[1,2,3]"),
540            Err(RpcError::NotJsonRpc(_))
541        ));
542        assert!(matches!(
543            parse_tool_call(br#"{"jsonrpc":"1.0","method":"tools/call"}"#),
544            Err(RpcError::NotJsonRpc(_))
545        ));
546        assert!(matches!(
547            parse_tool_call(br#"{"jsonrpc":"2.0"}"#),
548            Err(RpcError::NotJsonRpc(_))
549        ));
550        assert!(matches!(
551            parse_tool_call(br#"{"jsonrpc":"2.0","method":"resources/read"}"#),
552            Err(RpcError::NotToolCall(_))
553        ));
554        assert!(matches!(
555            parse_tool_call(br#"{"jsonrpc":"2.0","method":"tools/call"}"#),
556            Err(RpcError::BadParams(_))
557        ));
558        assert!(matches!(
559            parse_tool_call(br#"{"jsonrpc":"2.0","method":"tools/call","params":{"name":""}}"#),
560            Err(RpcError::BadParams(_))
561        ));
562        assert!(matches!(
563            parse_tool_call(
564                br#"{"jsonrpc":"2.0","method":"tools/call","params":{"name":"t","arguments":[1]}}"#
565            ),
566            Err(RpcError::BadParams(_))
567        ));
568    }
569
570    #[test]
571    fn oversized_payload_rejected_before_parse() {
572        let huge = vec![b'x'; MAX_PAYLOAD_BYTES + 1];
573        assert!(matches!(parse_tool_call(&huge), Err(RpcError::TooLarge(..))));
574    }
575
576    #[test]
577    fn max_payload_bytes_is_pinned_at_4_mib() {
578        // The DoS-guard cap is part of the public API. A `*` -> `+` mutation
579        // in `4 * 1024 * 1024` would slip past size-relative asserts because
580        // both sides self-reference the constant; pin the absolute value.
581        assert_eq!(MAX_PAYLOAD_BYTES, 4_194_304);
582    }
583
584    #[test]
585    fn deep_nesting_rejected() {
586        let mut nested = String::from("1");
587        for _ in 0..200 {
588            nested = format!("[{nested}]");
589        }
590        let raw = format!(
591            r#"{{"jsonrpc":"2.0","method":"tools/call","params":{{"name":"t","arguments":{{"x":{nested}}}}}}}"#
592        );
593        assert!(parse_tool_call(raw.as_bytes()).is_err());
594    }
595
596    #[test]
597    fn authorization_error_shape() {
598        let e = authorization_error(Some(&json!(9)), "budget exceeded");
599        assert_eq!(e["id"], 9);
600        assert_eq!(e["error"]["code"], -32001);
601        assert_eq!(e["error"]["data"]["reason"], "budget exceeded");
602    }
603
604    #[test]
605    fn denial_error_carries_machine_readable_code() {
606        let e = denial_error(
607            Some(&json!("req-7")),
608            DenialCode::BudgetExceeded,
609            "budget exceeded",
610        );
611        assert_eq!(e["jsonrpc"], "2.0");
612        assert_eq!(e["id"], "req-7");
613        assert_eq!(e["error"]["code"], -32001);
614        assert_eq!(e["error"]["data"]["reason"], "budget exceeded");
615        assert_eq!(e["error"]["data"]["code"], "BUDGET_EXCEEDED");
616    }
617
618    #[test]
619    fn protocol_errors_carry_denial_codes() {
620        let parse = protocol_error(None, &RpcError::Json("bad".into()));
621        assert_eq!(parse["error"]["code"], -32700);
622        assert_eq!(parse["error"]["data"]["code"], "PARSE_ERROR");
623        let passthrough = protocol_error(Some(&json!(3)), &RpcError::NotToolCall("tools/list".into()));
624        assert_eq!(passthrough["id"], 3);
625        assert_eq!(passthrough["error"]["code"], -32001);
626        assert_eq!(passthrough["error"]["data"]["code"], "PASSTHROUGH_REFUSED");
627    }
628
629    proptest! {
630        #![proptest_config(ProptestConfig::with_cases(256))]
631
632        /// The parser must never panic on arbitrary bytes (D13 total-parser).
633        #[test]
634        fn never_panics(bytes in prop::collection::vec(any::<u8>(), 0..2048)) {
635            let _ = parse_tool_call(&bytes);
636        }
637
638        /// Nor on arbitrary *valid JSON* of hostile shape.
639        #[test]
640        fn never_panics_on_arbitrary_json(s in "\\PC{0,500}") {
641            let _ = parse_tool_call(s.as_bytes());
642        }
643    }
644
645    /// Tool names carrying a Trojan-Source RLO or any zero-width character
646    /// must be rejected. Otherwise a hostile agent could send a payload
647    /// whose `params.name` renders as `db_write` on an operator's terminal
648    /// while the raw bytes read as a completely different tool identifier,
649    /// spoofing audit chains and receipts.
650    #[test]
651    fn tool_name_carrying_a_bidi_or_zero_width_character_is_rejected() {
652        for spoof in [
653            "db_write\u{202E}etirw_bd",
654            "\u{202E}db_write",
655            "db\u{200B}_write",
656            "db_write\u{200E}",
657            "db_write\u{2066}suffix",
658            "db_write\u{FEFF}",
659        ] {
660            let raw = call(spoof, json!({}));
661            match parse_tool_call(&raw) {
662                Err(RpcError::BadParams(reason)) => {
663                    assert!(reason.contains("spoofing"), "wrong reason: {reason}");
664                }
665                other => panic!("must reject {spoof:?}, got {other:?}"),
666            }
667        }
668    }
669
670    /// A tool name carrying ANY ASCII control character or whitespace must
671    /// be rejected. Otherwise `"db_write\n"` slips past exact-string matchers
672    /// for per-tool schemas, per-tool policy deny-lists, per-tool budget
673    /// caps, AND the harness consequential-tools workflow veto — and most
674    /// downstream MCP servers then normalize whitespace back to `"db_write"`,
675    /// completing the bypass.
676    #[test]
677    fn tool_name_with_control_char_or_whitespace_is_rejected() {
678        for hostile in [
679            "db_write\n",
680            "db_write\r",
681            "db_write\t",
682            "db_write\0",
683            "db_write ",
684            " db_write",
685            "db write",
686            "db_write\u{000B}",
687            "db_write\x7f",
688        ] {
689            let raw = call(hostile, json!({}));
690            match parse_tool_call(&raw) {
691                Err(RpcError::BadParams(reason)) => {
692                    assert!(
693                        reason.contains("control character or whitespace"),
694                        "wrong reason: {reason}",
695                    );
696                }
697                other => panic!("must reject {hostile:?}, got {other:?}"),
698            }
699        }
700    }
701
702    /// `tools/call` without an id is a JSON-RPC 2.0 notification. Accepting
703    /// one would let an attacker fire-and-forget consequential calls to drain
704    /// `max_total_tool_calls` and payout budget with no response to consume.
705    /// Notifications must be refused at the parse gate.
706    #[test]
707    fn tools_call_without_id_is_rejected_as_a_notification() {
708        let raw = serde_json::to_vec(&json!({
709            "jsonrpc": "2.0",
710            "method": "tools/call",
711            "params": {"name": "safe_tool", "arguments": {}}
712        }))
713        .unwrap();
714        match parse_tool_call(&raw) {
715            Err(RpcError::BadParams(reason)) => {
716                assert!(reason.contains("notification"), "wrong reason: {reason}");
717            }
718            other => panic!("notification must be rejected, got {other:?}"),
719        }
720    }
721
722    /// JSON-RPC 2.0 §4 restricts `id` to a String, Number, or Null.
723    /// Downstream correlation tables that key on id-as-string cannot survive
724    /// a structured id.
725    #[test]
726    fn tools_call_with_structured_id_is_rejected() {
727        for hostile_id in [
728            json!({"nested": true}),
729            json!([1, 2, 3]),
730            json!(true),
731            json!(false),
732        ] {
733            let raw = serde_json::to_vec(&json!({
734                "jsonrpc": "2.0",
735                "id": hostile_id,
736                "method": "tools/call",
737                "params": {"name": "safe_tool", "arguments": {}}
738            }))
739            .unwrap();
740            match parse_tool_call(&raw) {
741                Err(RpcError::BadParams(reason)) => {
742                    assert!(reason.contains("JSON-RPC 2.0"), "wrong reason: {reason}");
743                }
744                other => panic!("hostile id {hostile_id:?} must be rejected, got {other:?}"),
745            }
746        }
747    }
748
749    /// String, number, and null ids are all accepted (§4 spec shape).
750    #[test]
751    fn valid_id_shapes_are_accepted() {
752        for good_id in [json!("uuid-123"), json!(42), json!(-7), json!(null)] {
753            let raw = serde_json::to_vec(&json!({
754                "jsonrpc": "2.0",
755                "id": good_id,
756                "method": "tools/call",
757                "params": {"name": "safe_tool", "arguments": {}}
758            }))
759            .unwrap();
760            let parsed = parse_tool_call(&raw).unwrap_or_else(|e| panic!("{good_id:?}: {e:?}"));
761            assert_eq!(parsed.id, Some(good_id));
762        }
763    }
764
765    /// Tool-name case-sensitivity bypass: downstream deny-list /
766    /// budget-key / schema-lookup are byte-exact, but most MCP servers
767    /// apply ASCII case folding — so `DB_write` would bypass a policy
768    /// that denies `db_write` while executing the same tool. The parse
769    /// gate now requires lowercase up front.
770    #[test]
771    fn tools_call_with_uppercase_letters_in_name_is_rejected() {
772        for hostile in ["DB_write", "Delete", "readFile", "PING"] {
773            let raw = serde_json::to_vec(&json!({
774                "jsonrpc": "2.0",
775                "id": "case-attack",
776                "method": "tools/call",
777                "params": {"name": hostile, "arguments": {}}
778            }))
779            .unwrap();
780            let err = parse_tool_call(&raw).unwrap_err();
781            assert!(
782                matches!(err, RpcError::BadParams(ref msg) if msg.contains("lowercase")),
783                "expected lowercase-refusal for {hostile:?}, got {err:?}"
784            );
785        }
786    }
787
788    /// Unicode-normalization bypass: `de\u{0301}lete` (decomposed) is
789    /// visually identical to `délete` (precomposed) but has a different
790    /// byte string. NFKC-fold variants like `𝐝𝐛_𝐰𝐫𝐢𝐭𝐞` collapse to
791    /// `db_write` on the server side while the proxy sees a distinct
792    /// name. Requiring ASCII bytes collapses this attack surface.
793    #[test]
794    fn tools_call_with_non_ascii_bytes_in_name_is_rejected() {
795        for hostile in [
796            "d\u{0301}elete",
797            "de\u{0301}lete",
798            "délete",
799            "𝐝𝐛_𝐰𝐫𝐢𝐭𝐞",
800            "read_file",
801        ] {
802            let raw = serde_json::to_vec(&json!({
803                "jsonrpc": "2.0",
804                "id": "unicode-attack",
805                "method": "tools/call",
806                "params": {"name": hostile, "arguments": {}}
807            }))
808            .unwrap();
809            let err = parse_tool_call(&raw).unwrap_err();
810            assert!(
811                matches!(err, RpcError::BadParams(ref msg) if msg.contains("ASCII")),
812                "expected ASCII-only refusal for {hostile:?}, got {err:?}"
813            );
814        }
815    }
816    /// `reject_duplicate_keys`
817    /// used to wrap EVERY scanner error with the "duplicate JSON key
818    /// rejected: ..." prefix — misleading operator triage on
819    /// dup-key alerts because malformed JSON, EOF, and recursion
820    /// limits were all reported as if they were duplicate-key
821    /// rejections. Mirror the sentinel fix from
822    /// `av_receipts`: real duplicate-key errors still get the
823    /// dup-key prefix; parse errors surface their own class.
824    #[test]
825    fn duplicate_key_class_distinguished_from_generic_parse_error() {
826        // Duplicate top-level key → "duplicate JSON key rejected: ..."
827        let real_dup = serde_json::to_vec(&json!({
828            "jsonrpc": "2.0",
829            "id": "dup",
830            "method": "tools/call",
831            "params": {"name": "read", "arguments": {}}
832        }))
833        .unwrap();
834        // Inject a duplicate by hand — serde_json's default parser
835        // would take last-wins.
836        let dup_raw: Vec<u8> = {
837            let mut v = real_dup.clone();
838            // Insert a second `"jsonrpc": "2.0"` right after the
839            // opening brace.
840            let insertion = br#""jsonrpc":"2.0","#;
841            v.splice(1..1, insertion.iter().copied());
842            v
843        };
844        let err = parse_tool_call(&dup_raw).unwrap_err();
845        assert!(
846            matches!(&err, RpcError::Json(msg) if msg.contains("duplicate JSON key rejected")),
847            "real dup-key must carry the dup-key prefix, got {err:?}"
848        );
849
850        // Malformed JSON (unbalanced brace) → NO dup-key prefix.
851        let malformed: &[u8] = b"{\"jsonrpc\":\"2.0\",\"id\":1,";
852        let err = parse_tool_call(malformed).unwrap_err();
853        match &err {
854            RpcError::Json(msg) => assert!(
855                !msg.contains("duplicate JSON key rejected"),
856                "malformed JSON must not be labeled as duplicate-key, got {msg:?}"
857            ),
858            other => panic!("expected RpcError::Json for malformed input, got {other:?}"),
859        }
860
861        // Empty input → NO dup-key prefix.
862        let err = parse_tool_call(b"").unwrap_err();
863        if let RpcError::Json(msg) = &err {
864            assert!(
865                !msg.contains("duplicate JSON key rejected"),
866                "empty input must not be labeled as duplicate-key, got {msg:?}"
867            );
868        }
869    }
870
871    /// The scanner used
872    /// `deserialize_any`, which returns after the first complete JSON
873    /// value — so `{"ok":1}garbage` was silently accepted. A proxy
874    /// that inspected only the first value could disagree with a
875    /// downstream that concatenated the buffer differently
876    /// ("smuggled second document"). `Deserializer::end()` after
877    /// the scan closes this class.
878    #[test]
879    fn trailing_garbage_after_valid_json_is_refused() {
880        // A well-formed tools/call followed by trailing bytes.
881        let valid = serde_json::to_vec(&json!({
882            "jsonrpc": "2.0",
883            "id": "trail",
884            "method": "tools/call",
885            "params": {"name": "read", "arguments": {}}
886        }))
887        .unwrap();
888        let mut with_trail = valid.clone();
889        with_trail.extend_from_slice(b"garbage");
890        let err = parse_tool_call(&with_trail).unwrap_err();
891        assert!(
892            matches!(&err, RpcError::Json(msg) if msg.contains("trailing content")),
893            "trailing bytes must be refused, got {err:?}"
894        );
895        // A well-formed value with only trailing whitespace is
896        // still accepted — that's benign network padding.
897        let mut with_ws = valid.clone();
898        with_ws.extend_from_slice(b"   \n\t");
899        assert!(
900            parse_tool_call(&with_ws).is_ok(),
901            "trailing whitespace must be allowed"
902        );
903    }
904}
905
906#[cfg(test)]
907mod depth_boundary_tests {
908    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
909
910    use super::*;
911
912    /// Mutation-run hardening: the depth cap was only proven far
913    /// past the limit, so boundary mutants (`current + 1` -> `current * 1`,
914    /// deleting the Array arm) survived. Pin the exact boundary through the
915    /// public parser for BOTH container kinds. Depth accounting: the
916    /// envelope contributes 3 levels (root -> params -> arguments), and each
917    /// wrapper inside `arguments.k` adds one, so 61 wrappers sit exactly at
918    /// MAX_JSON_DEPTH = 64 and 62 exceed it.
919    #[test]
920    fn json_depth_boundary_is_exact_for_arrays_and_objects() {
921        let build = |inner: String| {
922            format!(
923                r#"{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"t","arguments":{{"k":{inner}}}}}}}"#
924            )
925        };
926        let arrays = |n: usize| format!("{}0{}", "[".repeat(n), "]".repeat(n));
927        let objects = |n: usize| format!("{}0{}", "{\"x\":".repeat(n), "}".repeat(n));
928        for shape in [&arrays as &dyn Fn(usize) -> String, &objects] {
929            let at_limit = build(shape(MAX_JSON_DEPTH - 3));
930            assert!(
931                parse_tool_call(at_limit.as_bytes()).is_ok(),
932                "depth exactly at MAX_JSON_DEPTH must parse"
933            );
934            let past = build(shape(MAX_JSON_DEPTH - 2));
935            let outcome = parse_tool_call(past.as_bytes());
936            assert!(
937                matches!(outcome, Err(RpcError::NotJsonRpc(ref m)) if m.contains("depth")),
938                "one past MAX_JSON_DEPTH must be refused, got {outcome:?}"
939            );
940        }
941        // Empty containers exercise the `unwrap_or(current + 1)` fallback
942        // arms — an empty object/array is still one level deep, so one
943        // sitting past the cap must be refused too.
944        for leaf in ["{}", "[]"] {
945            let wrappers = "{\"x\":".repeat(MAX_JSON_DEPTH - 3);
946            let closers = "}".repeat(MAX_JSON_DEPTH - 3);
947            let past = build(format!("{wrappers}{leaf}{closers}"));
948            let outcome = parse_tool_call(past.as_bytes());
949            assert!(
950                matches!(outcome, Err(RpcError::NotJsonRpc(ref m)) if m.contains("depth")),
951                "empty {leaf} leaf past MAX_JSON_DEPTH must be refused, got {outcome:?}"
952            );
953        }
954    }
955}