Skip to main content

monoloop_loop/transaction/
validation.rs

1//! Input payload and output-contract validation for linked tools.
2
3use monoloop_contracts::{
4    CanonicalToolError, CanonicalToolOutput, JsonSchema, ToolCompletion, ToolOutputContract,
5    ToolSuccessContract,
6};
7use serde_json::Value;
8
9/// Why input validation failed (caller maps to rejected tool result, not txn failure).
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub enum InputValidationFailure {
12    /// Payload exceeds byte limit.
13    OversizedInput,
14    /// JSON parse failed.
15    InvalidJson,
16    /// Nesting depth exceeded.
17    DepthExceeded,
18    /// Schema validation failed.
19    SchemaInvalid,
20}
21
22/// Why output validation failed (maps to runtime failure / ToolExchangeFailed).
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum OutputValidationFailure {
25    /// Encoded output exceeds max_output_bytes.
26    OversizedOutput,
27    /// Success body does not match declared success contract.
28    SuccessShapeMismatch,
29    /// Success schema invalid.
30    SuccessSchemaInvalid,
31    /// Domain error fields invalid or data schema mismatch.
32    DomainErrorInvalid,
33}
34
35/// Maximum JSON nesting depth accepted for tool arguments/results.
36pub const DEFAULT_MAX_JSON_DEPTH: u32 = 16;
37
38/// Validate raw argument JSON string against size, depth, and schema.
39pub fn validate_tool_input(
40    payload: &str,
41    schema: &JsonSchema,
42    max_input_bytes: usize,
43    max_depth: u32,
44) -> Result<Value, InputValidationFailure> {
45    if payload.len() > max_input_bytes {
46        return Err(InputValidationFailure::OversizedInput);
47    }
48    let value: Value =
49        serde_json::from_str(payload).map_err(|_| InputValidationFailure::InvalidJson)?;
50    if !json_depth_ok(&value, 0, max_depth) {
51        return Err(InputValidationFailure::DepthExceeded);
52    }
53    validate_against_schema(&value, schema).map_err(|_| InputValidationFailure::SchemaInvalid)?;
54    Ok(value)
55}
56
57/// Validate a handler completion against the tool output contract.
58pub fn validate_tool_completion(
59    completion: ToolCompletion,
60    contract: &ToolOutputContract,
61    max_output_bytes: usize,
62    max_error_message_bytes: usize,
63    max_depth: u32,
64) -> Result<ToolCompletion, OutputValidationFailure> {
65    match completion {
66        ToolCompletion::Succeeded(output) => {
67            validate_success_output(&output, &contract.success, max_output_bytes, max_depth)?;
68            Ok(ToolCompletion::Succeeded(output))
69        }
70        ToolCompletion::DomainFailed(err) => {
71            validate_domain_error(
72                &err,
73                contract.error_data_schema.as_ref(),
74                max_output_bytes,
75                max_error_message_bytes,
76                max_depth,
77            )?;
78            Ok(ToolCompletion::DomainFailed(err))
79        }
80        ToolCompletion::RuntimeFailed(e) => Ok(ToolCompletion::RuntimeFailed(e)),
81    }
82}
83
84fn validate_success_output(
85    output: &CanonicalToolOutput,
86    success: &ToolSuccessContract,
87    max_output_bytes: usize,
88    max_depth: u32,
89) -> Result<(), OutputValidationFailure> {
90    match (output, success) {
91        (CanonicalToolOutput::Json(v), ToolSuccessContract::Json { schema }) => {
92            if encoded_len(v) > max_output_bytes {
93                return Err(OutputValidationFailure::OversizedOutput);
94            }
95            if !json_depth_ok(v, 0, max_depth) {
96                return Err(OutputValidationFailure::SuccessSchemaInvalid);
97            }
98            validate_against_schema(v, schema)
99                .map_err(|_| OutputValidationFailure::SuccessSchemaInvalid)?;
100            Ok(())
101        }
102        (CanonicalToolOutput::Text(t), ToolSuccessContract::Text { .. }) => {
103            if t.len() > max_output_bytes {
104                return Err(OutputValidationFailure::OversizedOutput);
105            }
106            if t.chars()
107                .any(|c| c.is_control() && c != '\n' && c != '\t' && c != '\r')
108            {
109                return Err(OutputValidationFailure::SuccessShapeMismatch);
110            }
111            Ok(())
112        }
113        _ => Err(OutputValidationFailure::SuccessShapeMismatch),
114    }
115}
116
117fn validate_domain_error(
118    err: &CanonicalToolError,
119    data_schema: Option<&JsonSchema>,
120    max_output_bytes: usize,
121    max_error_message_bytes: usize,
122    max_depth: u32,
123) -> Result<(), OutputValidationFailure> {
124    // Re-validate bounds (handler may bypass try_new).
125    if err.code.is_empty()
126        || err.code.len() > 64
127        || err.code.chars().any(|c| c.is_control())
128        || err.message.is_empty()
129        || err.message.len() > max_error_message_bytes
130        || err.message.chars().any(|c| c.is_control())
131    {
132        return Err(OutputValidationFailure::DomainErrorInvalid);
133    }
134    if let Some(data) = &err.data {
135        if encoded_len(data) > max_output_bytes {
136            return Err(OutputValidationFailure::OversizedOutput);
137        }
138        if !json_depth_ok(data, 0, max_depth) {
139            return Err(OutputValidationFailure::DomainErrorInvalid);
140        }
141        if let Some(schema) = data_schema {
142            validate_against_schema(data, schema)
143                .map_err(|_| OutputValidationFailure::DomainErrorInvalid)?;
144        }
145    } else if data_schema.is_some() {
146        // Optional data when schema present is allowed (schema applies when data exists).
147    }
148    Ok(())
149}
150
151fn validate_against_schema(value: &Value, schema: &JsonSchema) -> Result<(), ()> {
152    let validator = jsonschema::validator_for(schema.as_value()).map_err(|_| ())?;
153    if validator.is_valid(value) {
154        Ok(())
155    } else {
156        Err(())
157    }
158}
159
160fn encoded_len(v: &Value) -> usize {
161    serde_json::to_vec(v).map(|b| b.len()).unwrap_or(usize::MAX)
162}
163
164fn json_depth_ok(value: &Value, depth: u32, max: u32) -> bool {
165    if depth > max {
166        return false;
167    }
168    match value {
169        Value::Array(items) => items.iter().all(|v| json_depth_ok(v, depth + 1, max)),
170        Value::Object(map) => map.values().all(|v| json_depth_ok(v, depth + 1, max)),
171        _ => true,
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use monoloop_contracts::JsonSchema;
179
180    #[test]
181    fn rejects_oversized_input() {
182        let schema = JsonSchema::try_new(serde_json::json!({"type": "object"})).unwrap();
183        let big = format!("{{\"x\":\"{}\"}}", "a".repeat(100));
184        let err = validate_tool_input(&big, &schema, 10, 16).unwrap_err();
185        assert_eq!(err, InputValidationFailure::OversizedInput);
186    }
187
188    #[test]
189    fn rejects_schema_invalid() {
190        let schema = JsonSchema::try_new(serde_json::json!({
191            "type": "object",
192            "properties": { "n": { "type": "integer" } },
193            "required": ["n"],
194            "additionalProperties": false
195        }))
196        .unwrap();
197        let err = validate_tool_input(r#"{"n":"nope"}"#, &schema, 1024, 16).unwrap_err();
198        assert_eq!(err, InputValidationFailure::SchemaInvalid);
199    }
200}