monoloop_loop/transaction/
validation.rs1use monoloop_contracts::{
4 CanonicalToolError, CanonicalToolOutput, JsonSchema, ToolCompletion, ToolOutputContract,
5 ToolSuccessContract,
6};
7use serde_json::Value;
8
9#[derive(Clone, Debug, PartialEq, Eq)]
11pub enum InputValidationFailure {
12 OversizedInput,
14 InvalidJson,
16 DepthExceeded,
18 SchemaInvalid,
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
24pub enum OutputValidationFailure {
25 OversizedOutput,
27 SuccessShapeMismatch,
29 SuccessSchemaInvalid,
31 DomainErrorInvalid,
33}
34
35pub const DEFAULT_MAX_JSON_DEPTH: u32 = 16;
37
38pub 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
57pub 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 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 }
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}