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