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
35#[allow(dead_code)] pub const DEFAULT_MAX_JSON_DEPTH: u32 = 16;
38
39pub 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
58pub 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 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 }
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}