1use crate::on_error::ErrorPolicy;
2
3use super::types::{
4 EvalConfig, Expected, SequenceRule, Settings, TestCase, TestStatus, ThresholdingConfig,
5};
6
7pub const SEMANTIC_SIMILARITY_EPSILON: f64 = 1e-6;
9
10pub(crate) fn is_default_otel(o: &crate::config::otel::OtelConfig) -> bool {
11 o == &crate::config::otel::OtelConfig::default()
12}
13
14pub(crate) fn is_default_thresholds(t: &crate::thresholds::ThresholdConfig) -> bool {
15 t == &crate::thresholds::ThresholdConfig::default()
16}
17
18pub(crate) fn is_default_error_policy(p: &ErrorPolicy) -> bool {
19 *p == ErrorPolicy::default()
20}
21
22pub(crate) fn is_default_settings(s: &Settings) -> bool {
23 s == &Settings::default()
24}
25
26pub(crate) fn vacuous_expected_field(e: &Expected) -> Option<&'static str> {
32 match e {
33 Expected::MustContain { must_contain } if must_contain.iter().all(String::is_empty) => {
34 Some("must_contain")
35 }
36 Expected::MustNotContain { must_not_contain } if must_not_contain.is_empty() => {
37 Some("must_not_contain")
38 }
39 Expected::RegexMatch { pattern, .. } if pattern.is_empty() => Some("pattern"),
40 Expected::SemanticSimilarityTo { min_score, .. }
41 if *min_score <= -1.0 + SEMANTIC_SIMILARITY_EPSILON =>
42 {
43 Some("min_score")
44 }
45 Expected::ArgsValid { policy, schema }
46 if schema.as_ref().is_some_and(args_policy_asserts_nothing)
47 || (policy.is_none() && schema.is_none()) =>
48 {
49 Some("policy/schema")
50 }
51 Expected::SequenceValid {
52 policy,
53 sequence,
54 rules,
55 } if (sequence.is_none() && rules.is_none() && policy.is_none())
56 || (sequence.is_none() && rules.as_ref().is_some_and(Vec::is_empty)) =>
57 {
58 Some("policy/sequence/rules")
59 }
60 Expected::ToolOutputValid { schemas }
61 if schemas.as_ref().is_none_or(schema_map_asserts_nothing) =>
62 {
63 Some("schemas")
64 }
65 Expected::ToolBlocklist { blocked } if blocked.is_empty() => Some("blocked"),
66 _ => None,
67 }
68}
69
70fn args_policy_asserts_nothing(schema: &serde_json::Value) -> bool {
78 let Some(root) = schema.as_object() else {
79 return schema_map_asserts_nothing(schema);
80 };
81 if root
83 .keys()
84 .any(|key| key != "$defs" && is_json_schema_keyword(key))
85 {
86 return false;
87 }
88 if has_structured_args_policy_shape(schema) {
89 let schemas_assert = root
90 .get("schemas")
91 .is_some_and(|schemas| !schema_map_asserts_nothing(schemas));
92 if schemas_assert || structured_controls_assert(root) {
93 return false;
94 }
95 return structured_policy_is_fully_understood(root);
100 }
101 schema_map_asserts_nothing(schema)
102}
103
104fn structured_policy_is_fully_understood(
112 root: &serde_json::Map<String, serde_json::Value>,
113) -> bool {
114 const KNOWN_ROOT: [&str; 8] = [
115 "version",
116 "name",
117 "tools",
118 "schemas",
119 "enforcement",
120 "allow",
121 "deny",
122 "$defs",
123 ];
124 if !root.keys().all(|key| KNOWN_ROOT.contains(&key.as_str())) {
125 return false;
126 }
127 fn well_formed_list(value: Option<&serde_json::Value>) -> bool {
128 value.is_none_or(|value| {
129 value
130 .as_array()
131 .is_some_and(|entries| entries.iter().all(serde_json::Value::is_string))
132 })
133 }
134 if !well_formed_list(root.get("allow")) || !well_formed_list(root.get("deny")) {
135 return false;
136 }
137 if let Some(tools) = root.get("tools") {
138 let Some(tools) = tools.as_object() else {
139 return false;
140 };
141 if !tools.keys().all(|k| matches!(k.as_str(), "allow" | "deny")) {
144 return false;
145 }
146 if !well_formed_list(tools.get("allow")) || !well_formed_list(tools.get("deny")) {
147 return false;
148 }
149 }
150 if root.get("schemas").is_some_and(|s| !s.is_object()) {
151 return false;
152 }
153 root.get("enforcement")
154 .is_none_or(serde_json::Value::is_object)
155}
156
157fn structured_controls_assert(root: &serde_json::Map<String, serde_json::Value>) -> bool {
158 fn list(value: Option<&serde_json::Value>) -> Vec<&str> {
159 value
160 .and_then(serde_json::Value::as_array)
161 .map(|entries| {
162 entries
163 .iter()
164 .filter_map(serde_json::Value::as_str)
165 .collect()
166 })
167 .unwrap_or_default()
168 }
169 let mut allow = list(root.get("allow"));
170 let mut deny = list(root.get("deny"));
171 if let Some(tools) = root.get("tools").and_then(serde_json::Value::as_object) {
172 allow.extend(list(tools.get("allow")));
173 deny.extend(list(tools.get("deny")));
174 }
175 if !deny.is_empty()
176 || (!allow.is_empty()
177 && !allow
178 .iter()
179 .any(|pattern| is_universal_tool_pattern(pattern)))
180 {
181 return true;
182 }
183 root.get("enforcement")
184 .and_then(serde_json::Value::as_object)
185 .and_then(|enforcement| enforcement.get("unconstrained_tools"))
186 .and_then(serde_json::Value::as_str)
187 == Some("deny")
188}
189
190fn schema_map_asserts_nothing(value: &serde_json::Value) -> bool {
191 value.as_object().is_some_and(|schemas| {
192 let shared_defs = schemas.get("$defs").and_then(serde_json::Value::as_object);
193 schemas
194 .iter()
195 .filter(|(tool, _)| tool.as_str() != "$defs")
196 .all(|(_, schema)| {
197 let mut materialized = schema.clone();
198 if let (Some(shared_defs), Some(schema)) =
199 (shared_defs, materialized.as_object_mut())
200 {
201 match schema.get_mut("$defs") {
202 Some(serde_json::Value::Object(local_defs)) => {
203 if shared_defs.keys().any(|name| local_defs.contains_key(name)) {
204 return false;
205 }
206 local_defs.extend(shared_defs.clone());
207 }
208 Some(_) => return false,
209 None => {
210 schema.insert(
211 "$defs".to_string(),
212 serde_json::Value::Object(shared_defs.clone()),
213 );
214 }
215 }
216 }
217 schema_asserts_nothing(&materialized)
218 })
219 })
220}
221
222fn schema_asserts_nothing(schema: &serde_json::Value) -> bool {
223 let dialect = SchemaDialect::from_schema(schema);
224 schema_asserts_nothing_inner(schema, schema, dialect, 0)
225}
226
227#[derive(Clone, Copy)]
228enum SchemaDialect {
229 Draft4,
230 Draft6,
231 Draft7,
232 Modern,
233}
234
235impl SchemaDialect {
236 fn from_schema(schema: &serde_json::Value) -> Self {
237 match schema
238 .get("$schema")
239 .and_then(serde_json::Value::as_str)
240 .unwrap_or_default()
241 {
242 value if value.contains("draft-04") => Self::Draft4,
243 value if value.contains("draft-06") => Self::Draft6,
244 value if value.contains("draft-07") => Self::Draft7,
245 _ => Self::Modern,
246 }
247 }
248
249 fn is_legacy(self) -> bool {
250 matches!(self, Self::Draft4 | Self::Draft6 | Self::Draft7)
251 }
252}
253
254fn schema_asserts_nothing_inner(
255 schema: &serde_json::Value,
256 root: &serde_json::Value,
257 dialect: SchemaDialect,
258 depth: usize,
259) -> bool {
260 if depth > 64 {
261 return false;
262 }
263 match schema {
264 serde_json::Value::Bool(value) => *value,
265 serde_json::Value::Object(schema) => {
266 let direct_assertion = schema.iter().any(|(keyword, value)| {
267 schema_keyword_asserts(keyword, value, schema, root, dialect, depth + 1)
268 });
269 let conditional_assertion = conditional_asserts(schema, root, dialect, depth + 1);
270 !direct_assertion && !conditional_assertion
271 }
272 _ => false,
273 }
274}
275
276fn schema_keyword_asserts(
277 keyword: &str,
278 value: &serde_json::Value,
279 containing_schema: &serde_json::Map<String, serde_json::Value>,
280 root: &serde_json::Value,
281 dialect: SchemaDialect,
282 depth: usize,
283) -> bool {
284 match keyword {
285 "$ref" => local_ref_target(value, root)
286 .is_none_or(|target| !schema_asserts_nothing_inner(target, root, dialect, depth)),
287 "$dynamicRef" | "$recursiveRef" | "type" | "enum" | "multipleOf" | "maximum"
288 | "minimum" | "maxLength" | "maxItems" | "maxProperties" => true,
289 "const" => !matches!(dialect, SchemaDialect::Draft4),
290 "exclusiveMaximum" | "exclusiveMinimum" => value.as_bool() != Some(false),
291 "pattern" => value.as_str() != Some(""),
292 "minLength" | "minItems" | "minProperties" => {
293 value.as_u64().is_none_or(|minimum| minimum > 0)
294 }
295 "uniqueItems" => value.as_bool() != Some(false),
296 "required" => value.as_array().is_some_and(|entries| !entries.is_empty()),
297 "dependentRequired" if !dialect.is_legacy() => value.as_object().is_some_and(|entries| {
298 entries
299 .values()
300 .any(|required| required.as_array().is_some_and(|names| !names.is_empty()))
301 }),
302 "properties" | "patternProperties" => value.as_object().is_some_and(|schemas| {
303 schemas
304 .values()
305 .any(|schema| !schema_asserts_nothing_inner(schema, root, dialect, depth))
306 }),
307 "dependentSchemas" if !dialect.is_legacy() => value.as_object().is_some_and(|schemas| {
308 schemas
309 .values()
310 .any(|schema| !schema_asserts_nothing_inner(schema, root, dialect, depth))
311 }),
312 "dependencies" => value.as_object().is_some_and(|dependencies| {
316 dependencies.values().any(|dependency| {
317 dependency
318 .as_array()
319 .is_some_and(|required| !required.is_empty())
320 || ((dependency.is_object() || dependency.is_boolean())
321 && !schema_asserts_nothing_inner(dependency, root, dialect, depth))
322 })
323 }),
324 "additionalProperties" | "items" => {
325 !schema_asserts_nothing_inner(value, root, dialect, depth)
326 }
327 "unevaluatedProperties" | "unevaluatedItems" if !dialect.is_legacy() => {
328 !schema_asserts_nothing_inner(value, root, dialect, depth)
329 }
330 "propertyNames" if !matches!(dialect, SchemaDialect::Draft4) => {
331 !schema_asserts_nothing_inner(value, root, dialect, depth)
332 }
333 "additionalItems"
334 if dialect.is_legacy()
335 && containing_schema
336 .get("items")
337 .is_some_and(serde_json::Value::is_array) =>
338 {
339 !schema_asserts_nothing_inner(value, root, dialect, depth)
340 }
341 "prefixItems" if !dialect.is_legacy() => value.as_array().is_some_and(|schemas| {
342 schemas
343 .iter()
344 .any(|schema| !schema_asserts_nothing_inner(schema, root, dialect, depth))
345 }),
346 "allOf" => value.as_array().is_some_and(|schemas| {
347 schemas
348 .iter()
349 .any(|schema| !schema_asserts_nothing_inner(schema, root, dialect, depth))
350 }),
351 "anyOf" => value.as_array().is_some_and(|schemas| {
352 schemas.is_empty()
353 || schemas
354 .iter()
355 .all(|schema| !schema_asserts_nothing_inner(schema, root, dialect, depth))
356 }),
357 "oneOf" => value
358 .as_array()
359 .is_some_and(|schemas| match schemas.as_slice() {
360 [] => true,
361 [schema] => !schema_asserts_nothing_inner(schema, root, dialect, depth),
362 schemas
363 if schemas
364 .iter()
365 .filter(|schema| schema.as_bool() == Some(true))
366 .count()
367 == 1
368 && schemas.iter().all(serde_json::Value::is_boolean) =>
369 {
370 false
371 }
372 _ => true,
373 }),
374 "not" => value != &serde_json::Value::Bool(false),
375 "contains" if matches!(dialect, SchemaDialect::Draft4) => false,
376 "contains" if dialect.is_legacy() => true,
377 "contains" => {
378 containing_schema
379 .get("minContains")
380 .and_then(serde_json::Value::as_u64)
381 != Some(0)
382 || containing_schema.contains_key("maxContains")
383 }
384 "format" if dialect.is_legacy() => is_known_format(value),
385 _ => false,
386 }
387}
388
389fn conditional_asserts(
390 schema: &serde_json::Map<String, serde_json::Value>,
391 root: &serde_json::Value,
392 dialect: SchemaDialect,
393 depth: usize,
394) -> bool {
395 if matches!(dialect, SchemaDialect::Draft4 | SchemaDialect::Draft6) {
396 return false;
397 }
398 let Some(condition) = schema.get("if") else {
399 return false;
400 };
401 let branch_asserts = |keyword| {
402 schema
403 .get(keyword)
404 .is_some_and(|branch| !schema_asserts_nothing_inner(branch, root, dialect, depth))
405 };
406 match condition.as_bool() {
407 Some(true) => branch_asserts("then"),
408 Some(false) => branch_asserts("else"),
409 None => branch_asserts("then") || branch_asserts("else"),
410 }
411}
412
413fn local_ref_target<'a>(
414 reference: &serde_json::Value,
415 root: &'a serde_json::Value,
416) -> Option<&'a serde_json::Value> {
417 let reference = reference.as_str()?;
418 let fragment = if let Some(fragment) = reference.strip_prefix('#') {
419 fragment
420 } else {
421 let root_id = root.get("$id")?.as_str()?;
422 reference.strip_prefix(root_id)?.strip_prefix('#')?
423 };
424 if fragment.is_empty() {
425 return None;
426 }
427 root.pointer(fragment)
428}
429
430fn is_known_format(value: &serde_json::Value) -> bool {
431 matches!(
432 value.as_str(),
433 Some(
434 "date"
435 | "date-time"
436 | "email"
437 | "hostname"
438 | "ipv4"
439 | "ipv6"
440 | "regex"
441 | "time"
442 | "uri"
443 | "uri-reference"
444 | "uri-template"
445 | "uuid"
446 )
447 )
448}
449
450pub(crate) fn non_executable_expected_reason(e: &Expected) -> Option<&'static str> {
452 match e {
453 Expected::JudgeCriteria { .. } => Some("judge_criteria has no registered evaluator"),
454 Expected::SequenceValid {
455 rules: Some(rules), ..
456 } => rules.iter().find_map(|rule| match rule {
457 SequenceRule::Require { .. }
458 | SequenceRule::Blocklist { .. }
459 | SequenceRule::Before { .. } => None,
460 SequenceRule::Eventually { .. } => {
461 Some("sequence rule eventually is not executable by sequence_valid")
462 }
463 SequenceRule::MaxCalls { .. } => {
464 Some("sequence rule max_calls is not executable by sequence_valid")
465 }
466 SequenceRule::After { .. } => {
467 Some("sequence rule after is not executable by sequence_valid")
468 }
469 SequenceRule::NeverAfter { .. } => {
470 Some("sequence rule never_after is not executable by sequence_valid")
471 }
472 SequenceRule::Sequence { .. } => {
473 Some("sequence rule sequence is not executable by sequence_valid")
474 }
475 }),
476 _ => None,
477 }
478}
479
480pub(crate) fn ineffective_expected_reason(e: &Expected) -> Option<&'static str> {
481 match e {
482 Expected::MustNotContain { must_not_contain }
483 if must_not_contain.iter().any(String::is_empty) =>
484 {
485 Some("must_not_contain contains an empty string, so no response can pass")
486 }
487 Expected::RegexNotMatch { pattern, .. } if pattern.is_empty() => {
488 Some("an empty regex_not_match pattern matches every response, so no response can pass")
489 }
490 Expected::SequenceValid {
491 rules: Some(rules), ..
492 } if rules.iter().any(|rule| {
493 matches!(
494 rule,
495 SequenceRule::Before { first, then } if first == then
496 )
497 }) =>
498 {
499 Some("a before rule with identical tools cannot constrain a trace")
500 }
501 _ => None,
502 }
503}
504
505pub(crate) fn validate_expected_for_execution(e: &Expected) -> anyhow::Result<()> {
507 if matches!(e, Expected::Reference { .. }) {
508 anyhow::bail!("unresolved `$ref` cannot be executed; resolve or migrate it first");
509 }
510 if let Some(field) = vacuous_expected_field(e) {
511 anyhow::bail!("`{field}` asserts nothing");
512 }
513 if let Some(reason) = non_executable_expected_reason(e) {
514 anyhow::bail!("expected block is not executable: {reason}");
515 }
516 if let Some(reason) = ineffective_expected_reason(e) {
517 anyhow::bail!("{reason}");
518 }
519 validate_static_inputs(e)?;
520 Ok(())
521}
522
523fn validate_static_inputs(e: &Expected) -> anyhow::Result<()> {
524 match e {
525 Expected::RegexMatch { pattern, flags } | Expected::RegexNotMatch { pattern, flags } => {
526 let mut builder = regex::RegexBuilder::new(pattern);
527 for flag in flags {
528 match flag.as_str() {
529 "i" => {
530 builder.case_insensitive(true);
531 }
532 "m" => {
533 builder.multi_line(true);
534 }
535 "s" => {
536 builder.dot_matches_new_line(true);
537 }
538 _ => {}
539 }
540 }
541 builder
542 .build()
543 .map_err(|e| anyhow::anyhow!("invalid regex pattern: {e}"))?;
544 }
545 Expected::JsonSchema {
546 json_schema,
547 schema_file,
548 } => {
549 let source = if let Some(path) = schema_file {
550 std::fs::read_to_string(path)
551 .map_err(|e| anyhow::anyhow!("failed to read schema_file '{path}': {e}"))?
552 } else {
553 json_schema.clone()
554 };
555 let schema: serde_json::Value = serde_json::from_str(&source)
556 .map_err(|e| anyhow::anyhow!("invalid JSON schema: {e}"))?;
557 crate::policy_engine::compile_schema(&schema)
558 .map_err(|e| anyhow::anyhow!("schema compile failed: {e}"))?;
559 }
560 Expected::ArgsValid {
561 schema: Some(schema),
562 ..
563 } => validate_args_policy_value(schema)?,
564 Expected::ToolOutputValid {
565 schemas: Some(schema),
566 } => validate_schema_map(schema, false, true)?,
567 Expected::ArgsValid {
568 policy: Some(path),
569 schema: None,
570 } => validate_args_policy(path)?,
571 Expected::SequenceValid {
572 policy: Some(path), ..
573 } => validate_sequence_policy(path)?,
574 _ => {}
575 }
576 Ok(())
577}
578
579pub(crate) fn bind_external_expected_inputs(e: &mut Expected) -> anyhow::Result<()> {
585 match e {
586 Expected::JsonSchema {
587 json_schema,
588 schema_file,
589 } => {
590 if let Some(path) = schema_file.take() {
591 *json_schema = std::fs::read_to_string(&path)
592 .map_err(|err| anyhow::anyhow!("failed to read schema_file '{path}': {err}"))?;
593 }
594 }
595 Expected::ArgsValid { policy, schema } if schema.is_none() => {
596 if let Some(path) = policy.take() {
597 let source = std::fs::read_to_string(&path).map_err(|err| {
598 anyhow::anyhow!("failed to read args_valid policy '{path}': {err}")
599 })?;
600 *schema = Some(
601 serde_yaml::from_str(&source)
602 .map_err(|err| anyhow::anyhow!("invalid args_valid policy YAML: {err}"))?,
603 );
604 }
605 }
606 Expected::SequenceValid {
607 policy,
608 sequence,
609 rules,
610 } => {
611 if let Some(path) = policy.take() {
612 let source = std::fs::read_to_string(&path).map_err(|err| {
613 anyhow::anyhow!("failed to read sequence_valid policy '{path}': {err}")
614 })?;
615 if let Ok(loaded) = serde_yaml::from_str::<Vec<String>>(&source) {
616 if sequence.is_none() {
617 *sequence = Some(loaded);
618 }
619 } else if let Ok(loaded) = serde_yaml::from_str::<super::types::Policy>(&source) {
620 if rules.is_none() {
621 *rules = Some(loaded.sequences);
622 }
623 } else {
624 let loaded =
625 serde_yaml::from_str::<Vec<SequenceRule>>(&source).map_err(|err| {
626 anyhow::anyhow!("invalid sequence_valid policy YAML: {err}")
627 })?;
628 if rules.is_none() {
629 *rules = Some(loaded);
630 }
631 }
632 }
633 }
634 _ => {}
635 }
636 Ok(())
637}
638
639fn validate_args_policy(path: &str) -> anyhow::Result<()> {
640 let source = std::fs::read_to_string(path)
641 .map_err(|e| anyhow::anyhow!("failed to read args_valid policy '{path}': {e}"))?;
642 let policy: serde_json::Value = serde_yaml::from_str(&source)
643 .map_err(|e| anyhow::anyhow!("invalid args_valid policy YAML: {e}"))?;
644
645 validate_args_policy_value(&policy)
646}
647
648pub fn validate_args_policy_value(policy: &serde_json::Value) -> anyhow::Result<()> {
650 if policy
651 .as_object()
652 .and_then(|root| root.get("version"))
653 .is_some_and(|version| !version.is_string())
654 {
655 anyhow::bail!(
656 "args_valid policy version must be a string; move a legacy tool named `version` under `version: \"2.0\"` and `schemas.version`"
657 );
658 }
659 if policy
660 .as_object()
661 .is_some_and(|root| root.len() == 1 && root.contains_key("schemas"))
662 {
663 anyhow::bail!(
664 "args_valid policy with only `schemas` is ambiguous; add `version: \"2.0\"` for a structured policy, including a tool named `schemas`"
665 );
666 }
667
668 let structured = has_structured_args_policy_shape(policy);
669
670 if structured {
671 const UNENFORCED: &[&str] = &[
672 "constraints",
673 "limits",
674 "signatures",
675 "tool_pins",
676 "discovery",
677 "runtime_monitor",
678 "kill_switch",
679 ];
680 let unsupported: Vec<_> = UNENFORCED
681 .iter()
682 .copied()
683 .filter(|key| policy.get(*key).is_some())
684 .collect();
685 if !unsupported.is_empty() {
686 anyhow::bail!(
687 "args_valid policy fields are not enforced by this evaluator: {}",
688 unsupported.join(", ")
689 );
690 }
691
692 let mut allow = policy_string_list(policy.get("allow"), "allow")?;
693 let mut deny = policy_string_list(policy.get("deny"), "deny")?;
694 if let Some(tools) = policy.get("tools") {
695 let tools = tools
696 .as_object()
697 .ok_or_else(|| anyhow::anyhow!("args_valid policy tools must be a mapping"))?;
698 let mut unsupported: Vec<_> = tools
699 .keys()
700 .filter(|key| !matches!(key.as_str(), "allow" | "deny"))
701 .map(|key| format!("tools.{key}"))
702 .collect();
703 unsupported.sort_unstable();
704 if !unsupported.is_empty() {
705 anyhow::bail!(
706 "args_valid policy fields are not enforced by this evaluator: {}",
707 unsupported.join(", ")
708 );
709 }
710 allow.extend(policy_string_list(tools.get("allow"), "tools.allow")?);
711 deny.extend(policy_string_list(tools.get("deny"), "tools.deny")?);
712 }
713 let mut effective = !deny.is_empty()
714 || (!allow.is_empty()
715 && !allow
716 .iter()
717 .any(|pattern| is_universal_tool_pattern(pattern)));
718
719 if let Some(schemas) = policy.get("schemas") {
720 let schemas = schemas
721 .as_object()
722 .ok_or_else(|| anyhow::anyhow!("args_valid policy schemas must be a mapping"))?;
723 if !schemas.is_empty() {
724 let schemas = serde_json::Value::Object(schemas.clone());
725 let prepared_schemas = crate::policy_engine::prepare_schema_map(&schemas)
726 .map_err(anyhow::Error::msg)?;
727 if !prepared_schemas
728 .as_object()
729 .is_some_and(serde_json::Map::is_empty)
730 {
731 validate_schema_map(&prepared_schemas, false, false)?;
732 }
733 effective |= !schema_map_asserts_nothing(&schemas);
734 }
735 }
736
737 if let Some(enforcement) = policy.get("enforcement") {
738 let enforcement = enforcement.as_object().ok_or_else(|| {
739 anyhow::anyhow!("args_valid policy enforcement must be a mapping")
740 })?;
741 if let Some(mode) = enforcement.get("unconstrained_tools") {
742 let mode = mode.as_str().ok_or_else(|| {
743 anyhow::anyhow!(
744 "args_valid policy enforcement.unconstrained_tools must be a string"
745 )
746 })?;
747 match mode {
748 "deny" => effective = true,
749 "warn" | "allow" => {}
750 _ => anyhow::bail!(
751 "args_valid policy enforcement.unconstrained_tools must be one of: warn, deny, allow"
752 ),
753 }
754 }
755 }
756
757 if !effective {
758 anyhow::bail!("args_valid policy asserts nothing enforced by this evaluator");
759 }
760 return Ok(());
761 }
762
763 validate_schema_map(policy, true, true)
764}
765
766fn policy_string_list<'a>(
767 value: Option<&'a serde_json::Value>,
768 field: &str,
769) -> anyhow::Result<Vec<&'a str>> {
770 let Some(value) = value else {
771 return Ok(Vec::new());
772 };
773 let values = value
774 .as_array()
775 .ok_or_else(|| anyhow::anyhow!("args_valid policy {field} must be a list"))?;
776 values
777 .iter()
778 .map(|value| {
779 value
780 .as_str()
781 .ok_or_else(|| anyhow::anyhow!("args_valid policy {field} entries must be strings"))
782 })
783 .collect()
784}
785
786fn is_universal_tool_pattern(pattern: &str) -> bool {
787 !pattern.is_empty() && pattern.bytes().all(|byte| byte == b'*')
788}
789
790pub fn has_structured_args_policy_shape(policy: &serde_json::Value) -> bool {
796 let Some(root) = policy.as_object() else {
797 return false;
798 };
799 root.get("version")
800 .is_some_and(serde_json::Value::is_string)
801 || root.get("name").is_some_and(serde_json::Value::is_string)
802 || root.get("allow").is_some_and(serde_json::Value::is_array)
803 || root.get("deny").is_some_and(serde_json::Value::is_array)
804 || root
805 .get("tools")
806 .and_then(serde_json::Value::as_object)
807 .is_some_and(|tools| {
808 [
809 "allow",
810 "deny",
811 "allow_classes",
812 "deny_classes",
813 "approval_required",
814 "approval_required_classes",
815 "restrict_scope",
816 "restrict_scope_classes",
817 "restrict_scope_contract",
818 "redact_args",
819 "redact_args_classes",
820 "redact_args_contract",
821 ]
822 .iter()
823 .any(|key| tools.contains_key(*key))
824 })
825 || root
826 .get("enforcement")
827 .and_then(serde_json::Value::as_object)
828 .is_some_and(|enforcement| enforcement.contains_key("unconstrained_tools"))
829 || [
830 "constraints",
831 "limits",
832 "signatures",
833 "tool_pins",
834 "discovery",
835 "runtime_monitor",
836 "kill_switch",
837 ]
838 .iter()
839 .any(|key| root.contains_key(*key))
840}
841
842fn validate_sequence_policy(path: &str) -> anyhow::Result<()> {
843 let source = std::fs::read_to_string(path)
844 .map_err(|e| anyhow::anyhow!("failed to read sequence_valid policy '{path}': {e}"))?;
845 if serde_yaml::from_str::<Vec<String>>(&source).is_ok() {
846 return Ok(());
847 }
848
849 let rules = if let Ok(policy) = serde_yaml::from_str::<super::types::Policy>(&source) {
850 policy.sequences
851 } else {
852 serde_yaml::from_str::<Vec<SequenceRule>>(&source)
853 .map_err(|e| anyhow::anyhow!("invalid sequence_valid policy YAML: {e}"))?
854 };
855 let expected = Expected::SequenceValid {
856 policy: None,
857 sequence: None,
858 rules: Some(rules),
859 };
860 if let Some(field) = vacuous_expected_field(&expected) {
861 anyhow::bail!("`{field}` asserts nothing");
862 }
863 if let Some(reason) = non_executable_expected_reason(&expected) {
864 anyhow::bail!("expected block is not executable: {reason}");
865 }
866 if let Some(reason) = ineffective_expected_reason(&expected) {
867 anyhow::bail!("{reason}");
868 }
869 Ok(())
870}
871
872fn validate_schema_map(
873 value: &serde_json::Value,
874 reject_root_schema_keywords: bool,
875 require_effective_schema: bool,
876) -> anyhow::Result<()> {
877 let schemas = value
878 .as_object()
879 .filter(|schemas| !schemas.is_empty())
880 .ok_or_else(|| anyhow::anyhow!("schema must be a non-empty tool-name-to-schema map"))?;
881 if reject_root_schema_keywords && schemas.keys().any(|key| is_json_schema_keyword(key)) {
882 anyhow::bail!(
883 "root JSON Schema keywords cannot be used as tool names; expected a tool-name-to-schema map"
884 );
885 }
886 if require_effective_schema && schema_map_asserts_nothing(value) {
887 anyhow::bail!("schema map asserts nothing");
888 }
889 for (tool, schema) in schemas {
890 if !schema.is_object() && !schema.is_boolean() {
891 anyhow::bail!(
892 "schema entry '{tool}' must be a JSON Schema; expected a tool-name-to-schema map"
893 );
894 }
895 crate::policy_engine::compile_schema(schema)
896 .map_err(|e| anyhow::anyhow!("schema for tool '{tool}' failed to compile: {e}"))?;
897 }
898 Ok(())
899}
900
901fn is_json_schema_keyword(key: &str) -> bool {
902 matches!(
903 key,
904 "$schema"
905 | "$id"
906 | "$ref"
907 | "$defs"
908 | "$anchor"
909 | "$dynamicRef"
910 | "$dynamicAnchor"
911 | "$vocabulary"
912 | "$comment"
913 | "id"
914 | "definitions"
915 | "dependencies"
916 | "additionalItems"
917 | "$recursiveRef"
918 | "$recursiveAnchor"
919 | "divisibleBy"
920 | "disallow"
921 | "extends"
922 | "type"
923 | "enum"
924 | "const"
925 | "multipleOf"
926 | "maximum"
927 | "exclusiveMaximum"
928 | "minimum"
929 | "exclusiveMinimum"
930 | "maxLength"
931 | "minLength"
932 | "pattern"
933 | "items"
934 | "prefixItems"
935 | "contains"
936 | "maxItems"
937 | "minItems"
938 | "uniqueItems"
939 | "maxContains"
940 | "minContains"
941 | "properties"
942 | "patternProperties"
943 | "additionalProperties"
944 | "propertyNames"
945 | "maxProperties"
946 | "minProperties"
947 | "required"
948 | "dependentRequired"
949 | "dependentSchemas"
950 | "unevaluatedItems"
951 | "unevaluatedProperties"
952 | "allOf"
953 | "anyOf"
954 | "oneOf"
955 | "not"
956 | "if"
957 | "then"
958 | "else"
959 | "title"
960 | "description"
961 | "default"
962 | "deprecated"
963 | "readOnly"
964 | "writeOnly"
965 | "examples"
966 | "format"
967 | "contentEncoding"
968 | "contentMediaType"
969 | "contentSchema"
970 )
971}
972
973pub(crate) fn validate_test_case_for_execution(test: &TestCase) -> anyhow::Result<()> {
975 if matches!(
979 &test.expected,
980 Expected::MustContain { must_contain } if must_contain.is_empty()
981 ) {
982 return Ok(());
983 }
984 validate_expected_for_execution(&test.expected)
985}
986
987pub(crate) fn is_omitted_expected_sentinel(e: &Expected) -> bool {
992 matches!(e, Expected::MustContain { must_contain } if must_contain.is_empty())
993}
994
995pub(crate) fn default_one() -> u32 {
996 1
997}
998
999pub(crate) fn default_min_score() -> f64 {
1000 0.80
1001}
1002
1003impl EvalConfig {
1004 pub fn is_legacy(&self) -> bool {
1005 self.version == 0
1006 }
1007
1008 pub fn has_legacy_usage(&self) -> bool {
1009 self.tests
1010 .iter()
1011 .any(|t: &TestCase| t.expected.get_policy_path().is_some())
1012 }
1013
1014 pub fn validate(&self) -> anyhow::Result<()> {
1015 if self.version >= 1 {
1016 for test in &self.tests {
1017 if matches!(test.expected, Expected::Reference { .. }) {
1018 anyhow::bail!("$ref in expected block is not allowed in configVersion >= 1. Run `assay migrate` to inline policies.");
1019 }
1020 }
1021 }
1022 Ok(())
1023 }
1024
1025 pub fn effective_error_policy(&self, test: &TestCase) -> ErrorPolicy {
1028 test.on_error.unwrap_or(self.settings.on_error)
1029 }
1030}
1031
1032impl Expected {
1033 pub fn get_policy_path(&self) -> Option<&str> {
1034 match self {
1035 Expected::ArgsValid { policy, .. } => policy.as_deref(),
1036 Expected::SequenceValid { policy, .. } => policy.as_deref(),
1037 _ => None,
1038 }
1039 }
1040
1041 pub fn thresholding_for_metric(&self, metric_name: &str) -> Option<&ThresholdingConfig> {
1043 match (metric_name, self) {
1044 ("semantic_similarity_to", Expected::SemanticSimilarityTo { thresholding, .. }) => {
1045 thresholding.as_ref()
1046 }
1047 ("faithfulness", Expected::Faithfulness { thresholding, .. }) => thresholding.as_ref(),
1048 ("relevance", Expected::Relevance { thresholding, .. }) => thresholding.as_ref(),
1049 _ => None,
1050 }
1051 }
1052}
1053
1054impl TestStatus {
1055 pub fn parse(s: &str) -> Self {
1056 match s {
1057 "pass" => TestStatus::Pass,
1058 "fail" => TestStatus::Fail,
1059 "flaky" => TestStatus::Flaky,
1060 "warn" => TestStatus::Warn,
1061 "error" => TestStatus::Error,
1062 "skipped" => TestStatus::Skipped,
1063 "unstable" => TestStatus::Unstable,
1064 "allowed_on_error" => TestStatus::AllowedOnError,
1065 _ => TestStatus::Error,
1066 }
1067 }
1068
1069 pub fn is_passing(&self) -> bool {
1071 matches!(
1072 self,
1073 TestStatus::Pass | TestStatus::AllowedOnError | TestStatus::Warn
1074 )
1075 }
1076
1077 pub fn is_blocking(&self) -> bool {
1079 matches!(self, TestStatus::Fail | TestStatus::Error)
1080 }
1081}