Skip to main content

assay_core/model/
validation.rs

1use crate::on_error::ErrorPolicy;
2
3use super::types::{
4    EvalConfig, Expected, SequenceRule, Settings, TestCase, TestStatus, ThresholdingConfig,
5};
6
7/// Tolerance used by the semantic-similarity evaluator at its pass boundary.
8pub 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
26/// The field or field group that leaves `expected` without an effective check.
27///
28/// Parsing and execution share this predicate because both must reject an
29/// explicitly inert assertion. Serialization deliberately uses a narrower
30/// predicate: only the synthetic omitted-key sentinel may disappear on write.
31pub(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
74/// Decide whether an `args_valid` policy asserts anything.
75///
76/// For a structured policy this mirrors the control-surface half of
77/// `validate_args_policy_value` exactly — merged root and `tools` allow/deny
78/// lists, `any` for the universality test, and `deny` as the only enforcement
79/// mode that makes a policy effective. Two approximations of the same rule
80/// drift; `args_policy_oracles_agree` pins them together.
81fn 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    // The root is itself a JSON Schema rather than a tool map.
86    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        // Claim vacuity only for a policy this rule fully understands. Anything
100        // else — an unenforced field, a malformed list, a non-mapping `schemas`
101        // — has a specific downstream diagnosis that is more useful than a
102        // generic "asserts nothing", so defer to it.
103        return structured_policy_is_fully_understood(root);
104    }
105    schema_map_asserts_nothing(schema)
106}
107
108/// Mirror of `validate_args_policy_value`'s control-surface effectiveness rule.
109/// Whether every part of a structured policy is something this rule models.
110///
111/// The vacuity verdict is only honest for a policy it fully understands. For
112/// anything else the execution-time check has a specific message ("not enforced
113/// by this evaluator", "must be a list", "must be a mapping") that is far more
114/// useful than "asserts nothing", so those defer to it.
115fn 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        // Execution enforces only `allow`/`deny`; every other key gets its own
146        // "not enforced by this evaluator" diagnosis.
147        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    // Delegate the $defs merge to the one preparation implementation instead of hand-rolling a
199    // copy of it here; two approximations of the same merge drift (#1951). A map that fails to
200    // prepare (a $defs collision, a non-mapping $defs) is NOT classified as vacuous: preparation
201    // failure is a malformed config with its own loud diagnosis at the validation and runtime
202    // layers, and claiming vacuity (or effectiveness) for it here would mask that.
203    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        // jsonschema compiles `dependencies` for every draft that declares the
304        // applicator vocabulary (keywords/mod.rs: `(_, "dependencies")`), so it
305        // asserts under the modern dialect too even though the spec dropped it.
306        "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
441/// Explain an `Expected` shape that the current metric set cannot execute as written.
442pub(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
504/// Reject any expected value that cannot safely reach metric dispatch.
505pub(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            // Prepare BEFORE validating, exactly as the structured args path does above, so a
571            // shared-$defs collision or a non-mapping $defs is a loud config error here rather
572            // than a silently vacuous schema at evaluation time (#1951). Validation, the vacuity
573            // rule, and the runtime metric all reason about the SAME prepared map.
574            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
590/// Replace file-backed assertion inputs with an immutable execution snapshot.
591///
592/// The returned `Expected` value is what validation, fingerprinting, and metric
593/// evaluation must all consume. This prevents incremental-cache drift and avoids
594/// a second file read after provider dispatch.
595pub(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
663/// Validate an inline `args_valid` policy using the execution-time contract.
664pub 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
805/// Return whether a value carries an unambiguous structured-policy discriminator.
806///
807/// `schemas` alone is intentionally not a discriminator: the same JSON shape can
808/// be a legacy schema map for a tool literally named `schemas`. Current policy
809/// documents identify themselves with `version: "2.0"` or another policy field.
810pub 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
988/// Validate the execution contract while preserving omitted-`expected` compatibility.
989pub(crate) fn validate_test_case_for_execution(test: &TestCase) -> anyhow::Result<()> {
990    // Deserialization represents an omitted `expected:` key with this exact default.
991    // A written empty block never reaches here because the parser rejects it. Keep
992    // the historical warning-only behavior until the assertion contract is tightened.
993    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
1002/// True for the legacy empty-`must_contain` sentinel.
1003///
1004/// `TestCase` does not retain whether this shape came from an omitted key or from
1005/// programmatic construction, so serialization cannot distinguish those origins.
1006pub(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    /// Get the effective error policy for a test.
1041    /// Test-level on_error overrides suite-level settings.
1042    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    /// Per-test thresholding for baseline regression (mode/max_drop) when this Expected variant matches the metric.
1061    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    /// Returns true if this status should be treated as passing for CI purposes
1089    pub fn is_passing(&self) -> bool {
1090        matches!(
1091            self,
1092            TestStatus::Pass | TestStatus::AllowedOnError | TestStatus::Warn
1093        )
1094    }
1095
1096    /// Returns true if this status should block CI
1097    pub fn is_blocking(&self) -> bool {
1098        matches!(self, TestStatus::Fail | TestStatus::Error)
1099    }
1100}