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.
36#[allow(dead_code)] // used by deferred dispatcher until M5
37pub const DEFAULT_MAX_JSON_DEPTH: u32 = 16;
38
39/// Validate raw argument JSON string against size, depth, and schema.
40pub fn validate_tool_input(
41    payload: &str,
42    schema: &JsonSchema,
43    max_input_bytes: usize,
44    max_depth: u32,
45) -> Result<Value, InputValidationFailure> {
46    if payload.len() > max_input_bytes {
47        return Err(InputValidationFailure::OversizedInput);
48    }
49    let value: Value =
50        serde_json::from_str(payload).map_err(|_| InputValidationFailure::InvalidJson)?;
51    if !json_depth_ok(&value, 0, max_depth) {
52        return Err(InputValidationFailure::DepthExceeded);
53    }
54    validate_against_schema(&value, schema).map_err(|_| InputValidationFailure::SchemaInvalid)?;
55    Ok(value)
56}
57
58/// Validate a handler completion against the tool output contract.
59pub fn validate_tool_completion(
60    completion: ToolCompletion,
61    contract: &ToolOutputContract,
62    max_output_bytes: usize,
63    max_error_message_bytes: usize,
64    max_depth: u32,
65) -> Result<ToolCompletion, OutputValidationFailure> {
66    match completion {
67        ToolCompletion::Succeeded(output) => {
68            validate_success_output(&output, &contract.success, max_output_bytes, max_depth)?;
69            Ok(ToolCompletion::Succeeded(output))
70        }
71        ToolCompletion::DomainFailed(err) => {
72            validate_domain_error(
73                &err,
74                contract.error_data_schema.as_ref(),
75                max_output_bytes,
76                max_error_message_bytes,
77                max_depth,
78            )?;
79            Ok(ToolCompletion::DomainFailed(err))
80        }
81        ToolCompletion::RuntimeFailed(e) => Ok(ToolCompletion::RuntimeFailed(e)),
82    }
83}
84
85fn validate_success_output(
86    output: &CanonicalToolOutput,
87    success: &ToolSuccessContract,
88    max_output_bytes: usize,
89    max_depth: u32,
90) -> Result<(), OutputValidationFailure> {
91    match (output, success) {
92        (CanonicalToolOutput::Json(v), ToolSuccessContract::Json { schema }) => {
93            if encoded_len(v) > max_output_bytes {
94                return Err(OutputValidationFailure::OversizedOutput);
95            }
96            if !json_depth_ok(v, 0, max_depth) {
97                return Err(OutputValidationFailure::SuccessSchemaInvalid);
98            }
99            validate_against_schema(v, schema)
100                .map_err(|_| OutputValidationFailure::SuccessSchemaInvalid)?;
101            Ok(())
102        }
103        (CanonicalToolOutput::Text(t), ToolSuccessContract::Text { .. }) => {
104            if t.len() > max_output_bytes {
105                return Err(OutputValidationFailure::OversizedOutput);
106            }
107            if t.chars()
108                .any(|c| c.is_control() && c != '\n' && c != '\t' && c != '\r')
109            {
110                return Err(OutputValidationFailure::SuccessShapeMismatch);
111            }
112            Ok(())
113        }
114        _ => Err(OutputValidationFailure::SuccessShapeMismatch),
115    }
116}
117
118fn validate_domain_error(
119    err: &CanonicalToolError,
120    data_schema: Option<&JsonSchema>,
121    max_output_bytes: usize,
122    max_error_message_bytes: usize,
123    max_depth: u32,
124) -> Result<(), OutputValidationFailure> {
125    // Re-validate bounds (handler may bypass try_new).
126    if err.code.is_empty()
127        || err.code.len() > 64
128        || err.code.chars().any(|c| c.is_control())
129        || err.message.is_empty()
130        || err.message.len() > max_error_message_bytes
131        || err.message.chars().any(|c| c.is_control())
132    {
133        return Err(OutputValidationFailure::DomainErrorInvalid);
134    }
135    if let Some(data) = &err.data {
136        if encoded_len(data) > max_output_bytes {
137            return Err(OutputValidationFailure::OversizedOutput);
138        }
139        if !json_depth_ok(data, 0, max_depth) {
140            return Err(OutputValidationFailure::DomainErrorInvalid);
141        }
142        if let Some(schema) = data_schema {
143            validate_against_schema(data, schema)
144                .map_err(|_| OutputValidationFailure::DomainErrorInvalid)?;
145        }
146    } else if data_schema.is_some() {
147        // Optional data when schema present is allowed (schema applies when data exists).
148    }
149    Ok(())
150}
151
152fn validate_against_schema(value: &Value, schema: &JsonSchema) -> Result<(), ()> {
153    let validator = jsonschema::validator_for(schema.as_value()).map_err(|_| ())?;
154    if validator.is_valid(value) {
155        Ok(())
156    } else {
157        Err(())
158    }
159}
160
161fn encoded_len(v: &Value) -> usize {
162    serde_json::to_vec(v).map(|b| b.len()).unwrap_or(usize::MAX)
163}
164
165fn json_depth_ok(value: &Value, depth: u32, max: u32) -> bool {
166    if depth > max {
167        return false;
168    }
169    match value {
170        Value::Array(items) => items.iter().all(|v| json_depth_ok(v, depth + 1, max)),
171        Value::Object(map) => map.values().all(|v| json_depth_ok(v, depth + 1, max)),
172        _ => true,
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use monoloop_contracts::JsonSchema;
180
181    #[test]
182    fn rejects_oversized_input() {
183        let schema = JsonSchema::try_new(serde_json::json!({"type": "object"})).unwrap();
184        let big = format!("{{\"x\":\"{}\"}}", "a".repeat(100));
185        let err = validate_tool_input(&big, &schema, 10, 16).unwrap_err();
186        assert_eq!(err, InputValidationFailure::OversizedInput);
187    }
188
189    #[test]
190    fn rejects_schema_invalid() {
191        let schema = JsonSchema::try_new(serde_json::json!({
192            "type": "object",
193            "properties": { "n": { "type": "integer" } },
194            "required": ["n"],
195            "additionalProperties": false
196        }))
197        .unwrap();
198        let err = validate_tool_input(r#"{"n":"nope"}"#, &schema, 1024, 16).unwrap_err();
199        assert_eq!(err, InputValidationFailure::SchemaInvalid);
200    }
201}