use monoloop_contracts::{
CanonicalToolError, CanonicalToolOutput, JsonSchema, ToolCompletion, ToolOutputContract,
ToolSuccessContract,
};
use serde_json::Value;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InputValidationFailure {
OversizedInput,
InvalidJson,
DepthExceeded,
SchemaInvalid,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OutputValidationFailure {
OversizedOutput,
SuccessShapeMismatch,
SuccessSchemaInvalid,
DomainErrorInvalid,
}
pub const DEFAULT_MAX_JSON_DEPTH: u32 = 16;
pub fn validate_tool_input(
payload: &str,
schema: &JsonSchema,
max_input_bytes: usize,
max_depth: u32,
) -> Result<Value, InputValidationFailure> {
if payload.len() > max_input_bytes {
return Err(InputValidationFailure::OversizedInput);
}
let value: Value =
serde_json::from_str(payload).map_err(|_| InputValidationFailure::InvalidJson)?;
if !json_depth_ok(&value, 0, max_depth) {
return Err(InputValidationFailure::DepthExceeded);
}
validate_against_schema(&value, schema).map_err(|_| InputValidationFailure::SchemaInvalid)?;
Ok(value)
}
pub fn validate_tool_completion(
completion: ToolCompletion,
contract: &ToolOutputContract,
max_output_bytes: usize,
max_error_message_bytes: usize,
max_depth: u32,
) -> Result<ToolCompletion, OutputValidationFailure> {
match completion {
ToolCompletion::Succeeded(output) => {
validate_success_output(&output, &contract.success, max_output_bytes, max_depth)?;
Ok(ToolCompletion::Succeeded(output))
}
ToolCompletion::DomainFailed(err) => {
validate_domain_error(
&err,
contract.error_data_schema.as_ref(),
max_output_bytes,
max_error_message_bytes,
max_depth,
)?;
Ok(ToolCompletion::DomainFailed(err))
}
ToolCompletion::RuntimeFailed(e) => Ok(ToolCompletion::RuntimeFailed(e)),
}
}
fn validate_success_output(
output: &CanonicalToolOutput,
success: &ToolSuccessContract,
max_output_bytes: usize,
max_depth: u32,
) -> Result<(), OutputValidationFailure> {
match (output, success) {
(CanonicalToolOutput::Json(v), ToolSuccessContract::Json { schema }) => {
if encoded_len(v) > max_output_bytes {
return Err(OutputValidationFailure::OversizedOutput);
}
if !json_depth_ok(v, 0, max_depth) {
return Err(OutputValidationFailure::SuccessSchemaInvalid);
}
validate_against_schema(v, schema)
.map_err(|_| OutputValidationFailure::SuccessSchemaInvalid)?;
Ok(())
}
(CanonicalToolOutput::Text(t), ToolSuccessContract::Text { .. }) => {
if t.len() > max_output_bytes {
return Err(OutputValidationFailure::OversizedOutput);
}
if t.chars()
.any(|c| c.is_control() && c != '\n' && c != '\t' && c != '\r')
{
return Err(OutputValidationFailure::SuccessShapeMismatch);
}
Ok(())
}
_ => Err(OutputValidationFailure::SuccessShapeMismatch),
}
}
fn validate_domain_error(
err: &CanonicalToolError,
data_schema: Option<&JsonSchema>,
max_output_bytes: usize,
max_error_message_bytes: usize,
max_depth: u32,
) -> Result<(), OutputValidationFailure> {
if err.code.is_empty()
|| err.code.len() > 64
|| err.code.chars().any(|c| c.is_control())
|| err.message.is_empty()
|| err.message.len() > max_error_message_bytes
|| err.message.chars().any(|c| c.is_control())
{
return Err(OutputValidationFailure::DomainErrorInvalid);
}
if let Some(data) = &err.data {
if encoded_len(data) > max_output_bytes {
return Err(OutputValidationFailure::OversizedOutput);
}
if !json_depth_ok(data, 0, max_depth) {
return Err(OutputValidationFailure::DomainErrorInvalid);
}
if let Some(schema) = data_schema {
validate_against_schema(data, schema)
.map_err(|_| OutputValidationFailure::DomainErrorInvalid)?;
}
} else if data_schema.is_some() {
}
Ok(())
}
fn validate_against_schema(value: &Value, schema: &JsonSchema) -> Result<(), ()> {
let validator = jsonschema::validator_for(schema.as_value()).map_err(|_| ())?;
if validator.is_valid(value) {
Ok(())
} else {
Err(())
}
}
fn encoded_len(v: &Value) -> usize {
serde_json::to_vec(v).map(|b| b.len()).unwrap_or(usize::MAX)
}
fn json_depth_ok(value: &Value, depth: u32, max: u32) -> bool {
if depth > max {
return false;
}
match value {
Value::Array(items) => items.iter().all(|v| json_depth_ok(v, depth + 1, max)),
Value::Object(map) => map.values().all(|v| json_depth_ok(v, depth + 1, max)),
_ => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
use monoloop_contracts::JsonSchema;
#[test]
fn rejects_oversized_input() {
let schema = JsonSchema::try_new(serde_json::json!({"type": "object"})).unwrap();
let big = format!("{{\"x\":\"{}\"}}", "a".repeat(100));
let err = validate_tool_input(&big, &schema, 10, 16).unwrap_err();
assert_eq!(err, InputValidationFailure::OversizedInput);
}
#[test]
fn rejects_schema_invalid() {
let schema = JsonSchema::try_new(serde_json::json!({
"type": "object",
"properties": { "n": { "type": "integer" } },
"required": ["n"],
"additionalProperties": false
}))
.unwrap();
let err = validate_tool_input(r#"{"n":"nope"}"#, &schema, 1024, 16).unwrap_err();
assert_eq!(err, InputValidationFailure::SchemaInvalid);
}
}