1use std::collections::HashSet;
5use std::path::{Component, Path, PathBuf};
6
7use regex::Regex;
8use serde_json::Value;
9
10use crate::core::{Assertion, DeliverWhen, EvalsConfig};
11use crate::validation::error::ValidationError;
12use crate::validation::schema::{SchemaName, validate_against_schema};
13
14pub fn validate_evals_config(config: &Value, source: &str) -> Result<EvalsConfig, ValidationError> {
18 let validated: EvalsConfig = validate_against_schema(SchemaName::Evals, config, source)?;
19
20 let mut seen = HashSet::new();
21 for (index, ev) in validated.evals.iter().enumerate() {
22 if !seen.insert(ev.id.as_str()) {
23 return Err(ValidationError::DuplicateId {
24 path: source.to_string(),
25 index,
26 id: ev.id.clone(),
27 });
28 }
29
30 for (turn_index, turn) in ev.turns.as_deref().unwrap_or(&[]).iter().enumerate() {
31 if turn.prompt.trim().is_empty() {
32 return Err(ValidationError::InvalidConfig {
33 path: source.to_string(),
34 message: format!(
35 "eval '{}', turns[{turn_index}]: prompt must contain non-whitespace text",
36 ev.id
37 ),
38 });
39 }
40 if turn.deliver_when == DeliverWhen::Always && turn.agent_response_matches.is_some() {
41 return Err(ValidationError::InvalidConfig {
42 path: source.to_string(),
43 message: format!(
44 "eval '{}', turns[{turn_index}]: agent_response_matches is only valid when deliver_when is 'agent_asks'",
45 ev.id
46 ),
47 });
48 }
49 if let Some(pattern) = &turn.agent_response_matches
50 && let Err(error) = Regex::new(pattern)
51 {
52 return Err(ValidationError::InvalidConfig {
53 path: source.to_string(),
54 message: format!(
55 "eval '{}', turns[{turn_index}]: invalid agent_response_matches regex {pattern:?}: {error}",
56 ev.id
57 ),
58 });
59 }
60 }
61
62 let visible = ev.files.as_deref().unwrap_or(&[]);
63 for assertion in ev.assertions.as_deref().unwrap_or(&[]) {
64 if let Assertion::TranscriptCheck(check) = assertion {
65 if check.check == "assistant_message_matches" && ev.turns.is_none() {
66 return Err(ValidationError::InvalidConfig {
67 path: source.to_string(),
68 message: format!(
69 "eval '{}', transcript_check '{}': assistant_message_matches \
70 requires a non-empty turns array",
71 ev.id, check.id
72 ),
73 });
74 }
75 if let Some(pattern) = &check.pattern
76 && let Err(error) = Regex::new(pattern)
77 {
78 return Err(ValidationError::InvalidConfig {
79 path: source.to_string(),
80 message: format!(
81 "eval '{}', transcript_check '{}': invalid pattern regex \
82 {pattern:?}: {error}",
83 ev.id, check.id
84 ),
85 });
86 }
87 }
88 let Assertion::CommandCheck(check) = assertion else {
89 continue;
90 };
91 for (name, value) in check.env.as_ref().into_iter().flatten() {
92 validate_environment_name(source, &ev.id, &check.id, "env", name)?;
93 validate_environment_value(source, &ev.id, &check.id, "env", name, value)?;
94 }
95 for (name, values) in check.matrix.as_ref().into_iter().flatten() {
96 validate_environment_name(source, &ev.id, &check.id, "matrix", name)?;
97 for value in values {
98 validate_environment_value(source, &ev.id, &check.id, "matrix", name, value)?;
99 }
100 }
101 for setup in check.setup_files.as_deref().unwrap_or(&[]) {
102 let setup_path = normalize_relative(setup).map_err(|()| {
103 ValidationError::InvalidConfig {
104 path: source.to_string(),
105 message: format!(
106 "eval '{}', command_check '{}': setup_files path must be relative and stay within the task environment: {setup}",
107 ev.id, check.id
108 ),
109 }
110 })?;
111 for fixture in visible {
112 let Ok(fixture_path) = normalize_relative(fixture) else {
113 continue;
114 };
115 if paths_overlap(&fixture_path, &setup_path) {
116 return Err(ValidationError::InvalidConfig {
117 path: source.to_string(),
118 message: format!(
119 "eval '{}', command_check '{}': visible fixture '{}' and setup_files path '{}' overlap; held-out setup paths must be disjoint from agent-visible files",
120 ev.id, check.id, fixture, setup
121 ),
122 });
123 }
124 }
125 }
126 }
127 }
128
129 Ok(validated)
130}
131
132fn validate_environment_name(
133 source: &str,
134 eval_id: &str,
135 check_id: &str,
136 field: &str,
137 name: &str,
138) -> Result<(), ValidationError> {
139 if name.is_empty() || name.contains('=') || name.contains('\0') {
140 return Err(ValidationError::InvalidConfig {
141 path: source.to_string(),
142 message: format!(
143 "eval '{eval_id}', command_check '{check_id}': {field} environment variable name must be non-empty and contain neither '=' nor NUL: {name:?}"
144 ),
145 });
146 }
147 Ok(())
148}
149
150fn validate_environment_value(
151 source: &str,
152 eval_id: &str,
153 check_id: &str,
154 field: &str,
155 name: &str,
156 value: &str,
157) -> Result<(), ValidationError> {
158 if value.contains('\0') {
159 return Err(ValidationError::InvalidConfig {
160 path: source.to_string(),
161 message: format!(
162 "eval '{eval_id}', command_check '{check_id}': {field} environment variable {name:?} value must not contain NUL"
163 ),
164 });
165 }
166 Ok(())
167}
168
169fn normalize_relative(value: &str) -> Result<PathBuf, ()> {
170 let mut normalized = PathBuf::new();
171 for component in Path::new(value).components() {
172 match component {
173 Component::Normal(part) => normalized.push(part),
174 Component::CurDir => {}
175 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return Err(()),
176 }
177 }
178 Ok(normalized)
179}
180
181fn paths_overlap(left: &Path, right: &Path) -> bool {
182 left.starts_with(right) || right.starts_with(left)
183}
184
185#[cfg(test)]
186mod tests {
187 use super::validate_evals_config;
188 use serde_json::{Value, json};
189
190 fn base() -> Value {
192 json!({
193 "skill_name": "demo",
194 "evals": [
195 {
196 "id": "e1",
197 "prompt": "do the thing",
198 "expected_output": "the thing is done"
199 }
200 ]
201 })
202 }
203
204 #[test]
205 fn accepts_a_boolean_skill_should_trigger() {
206 let mut config = base();
207 config["evals"][0]["skill_should_trigger"] = json!(false);
208 let parsed = validate_evals_config(&config, "evals.json").unwrap();
209 assert_eq!(parsed.evals[0].skill_should_trigger, Some(false));
210 }
211
212 #[test]
213 fn accepts_evals_with_no_skill_should_trigger() {
214 let config = base();
215 let parsed = validate_evals_config(&config, "evals.json").unwrap();
216 assert_eq!(parsed.skill_name, "demo");
217 assert_eq!(parsed.evals[0].skill_should_trigger, None);
218 }
219
220 #[test]
221 fn rejects_a_non_boolean_skill_should_trigger() {
222 let mut config = base();
223 config["evals"][0]["skill_should_trigger"] = json!("false");
224 let err = validate_evals_config(&config, "evals.json")
225 .unwrap_err()
226 .to_string();
227 assert!(err.contains("skill_should_trigger"), "error was: {err}");
228 }
229
230 #[test]
231 fn accepts_isolation_isolated() {
232 let mut config = base();
233 config["evals"][0]["isolation"] = json!("isolated");
234 let parsed = validate_evals_config(&config, "evals.json").unwrap();
235 assert_eq!(
236 parsed.evals[0].isolation,
237 Some(crate::core::Isolation::Isolated)
238 );
239 }
240
241 #[test]
242 fn accepts_isolation_shared() {
243 let mut config = base();
244 config["evals"][0]["isolation"] = json!("shared");
245 let parsed = validate_evals_config(&config, "evals.json").unwrap();
246 assert_eq!(
247 parsed.evals[0].isolation,
248 Some(crate::core::Isolation::Shared)
249 );
250 }
251
252 #[test]
253 fn defaults_isolation_to_none_when_absent() {
254 let config = base();
255 let parsed = validate_evals_config(&config, "evals.json").unwrap();
256 assert_eq!(parsed.evals[0].isolation, None);
257 }
258
259 #[test]
260 fn rejects_an_unknown_isolation_value() {
261 let mut config = base();
262 config["evals"][0]["isolation"] = json!("sometimes");
263 let err = validate_evals_config(&config, "evals.json")
264 .unwrap_err()
265 .to_string();
266 assert!(err.contains("isolation"), "error was: {err}");
267 }
268
269 #[test]
270 fn rejects_a_non_kebab_case_id() {
271 let mut config = base();
272 config["evals"][0]["id"] = json!("Not Kebab");
273 assert!(validate_evals_config(&config, "evals.json").is_err());
274 }
275
276 #[test]
277 fn rejects_duplicate_eval_ids() {
278 let mut config = base();
279 let dup = config["evals"][0].clone();
280 config["evals"] = json!([dup.clone(), dup]);
281 let err = validate_evals_config(&config, "evals.json")
282 .unwrap_err()
283 .to_string();
284 assert!(err.contains("duplicate"), "error was: {err}");
285 }
286
287 #[test]
288 fn rejects_an_empty_evals_array() {
289 let mut config = base();
290 config["evals"] = json!([]);
291 assert!(validate_evals_config(&config, "evals.json").is_err());
292 }
293
294 #[test]
295 fn accepts_ordered_scripted_follow_up_turns() {
296 let mut config = base();
297 config["evals"][0]["turns"] = json!([
298 {
299 "prompt": "Which users are affected?",
300 "deliver_when": "always"
301 },
302 {
303 "prompt": "They are all in US timezones and this is a date-only field.",
304 "deliver_when": "agent_asks",
305 "agent_response_matches": "(?i)(time ?zone|date-only)"
306 }
307 ]);
308
309 let parsed = validate_evals_config(&config, "evals.json").unwrap();
310 let turns = parsed.evals[0].turns.as_ref().unwrap();
311 assert_eq!(turns.len(), 2);
312 assert_eq!(turns[0].prompt, "Which users are affected?");
313 assert_eq!(turns[0].deliver_when, crate::core::DeliverWhen::Always);
314 assert_eq!(turns[1].deliver_when, crate::core::DeliverWhen::AgentAsks);
315 assert_eq!(
316 turns[1].agent_response_matches.as_deref(),
317 Some("(?i)(time ?zone|date-only)")
318 );
319 }
320
321 #[test]
322 fn rejects_assistant_message_check_without_scripted_turns() {
323 let mut config = base();
324 config["evals"][0]["assertions"] = json!([{
325 "id": "asked",
326 "type": "transcript_check",
327 "check": "assistant_message_matches",
328 "pattern": "timezone"
329 }]);
330 let err = validate_evals_config(&config, "evals.json")
331 .unwrap_err()
332 .to_string();
333 assert!(err.contains("assistant_message_matches"), "{err}");
334 assert!(err.contains("turns"), "{err}");
335 }
336
337 #[test]
338 fn rejects_invalid_scripted_turn_shapes() {
339 for (turn, expected) in [
340 (json!({"prompt": "", "deliver_when": "always"}), "prompt"),
341 (
342 json!({"prompt": "answer", "deliver_when": "sometimes"}),
343 "deliver_when",
344 ),
345 (
346 json!({
347 "prompt": "answer",
348 "deliver_when": "always",
349 "agent_response_matches": "topic"
350 }),
351 "agent_response_matches",
352 ),
353 (
354 json!({
355 "prompt": "answer",
356 "deliver_when": "agent_asks",
357 "agent_response_matches": "("
358 }),
359 "agent_response_matches",
360 ),
361 ] {
362 let mut config = base();
363 config["evals"][0]["turns"] = json!([turn]);
364 let error = validate_evals_config(&config, "evals.json")
365 .unwrap_err()
366 .to_string();
367 assert!(error.contains(expected), "{expected}: {error}");
368 }
369 }
370
371 #[test]
372 fn rejects_an_empty_scripted_turns_array() {
373 let mut config = base();
374 config["evals"][0]["turns"] = json!([]);
375 let error = validate_evals_config(&config, "evals.json")
376 .unwrap_err()
377 .to_string();
378 assert!(error.contains("turns"), "{error}");
379 }
380
381 #[test]
382 fn accepts_command_check_with_optional_setup_stdout_and_exit_code() {
383 let mut config = base();
384 config["evals"][0]["assertions"] = json!([
385 {
386 "id": "default-exit",
387 "type": "command_check",
388 "command": "cargo test"
389 },
390 {
391 "id": "full",
392 "type": "command_check",
393 "setup_files": ["holdout/test.rs"],
394 "command": "cargo test --test holdout",
395 "expect_exit_code": 2,
396 "expect_stdout": "2 tests passed"
397 }
398 ]);
399
400 let parsed = validate_evals_config(&config, "evals.json").unwrap();
401 let assertions = parsed.evals[0].assertions.as_ref().unwrap();
402 let crate::core::Assertion::CommandCheck(defaulted) = &assertions[0] else {
403 panic!("expected command_check");
404 };
405 assert_eq!(defaulted.expect_exit_code, 0);
406 let crate::core::Assertion::CommandCheck(full) = &assertions[1] else {
407 panic!("expected command_check");
408 };
409 assert_eq!(
410 full.setup_files.as_deref(),
411 Some(&["holdout/test.rs".into()][..])
412 );
413 assert_eq!(full.expect_exit_code, 2);
414 assert_eq!(full.expect_stdout.as_deref(), Some("2 tests passed"));
415 }
416
417 #[test]
418 fn accepts_command_check_environment_and_matrix() {
419 let mut config = base();
420 config["evals"][0]["assertions"] = json!([{
421 "id": "timezone-matrix",
422 "type": "command_check",
423 "command": "bun test",
424 "env": {
425 "CI": "1",
426 "EMPTY": "",
427 "TZ": "UTC"
428 },
429 "matrix": {
430 "LOCALE": ["en_US", "de_DE"],
431 "MODE": ["", "strict"],
432 "TZ": ["UTC", "America/Los_Angeles"]
433 }
434 }]);
435
436 let parsed = validate_evals_config(&config, "evals.json").unwrap();
437 let crate::core::Assertion::CommandCheck(check) =
438 &parsed.evals[0].assertions.as_ref().unwrap()[0]
439 else {
440 panic!("expected command_check");
441 };
442 assert_eq!(check.env.as_ref().unwrap()["CI"], "1");
443 assert_eq!(check.env.as_ref().unwrap()["EMPTY"], "");
444 assert_eq!(check.matrix.as_ref().unwrap()["MODE"][0], "");
445 assert_eq!(
446 check.matrix.as_ref().unwrap()["TZ"],
447 ["UTC", "America/Los_Angeles"]
448 );
449 }
450
451 #[test]
452 fn rejects_invalid_command_check_environment_names_and_nul_values() {
453 for (field, value) in [
454 ("env", json!({ "BAD=NAME": "value" })),
455 ("env", json!({ "GOOD_NAME": "bad\u{0}value" })),
456 ("matrix", json!({ "BAD=NAME": ["value"] })),
457 ("matrix", json!({ "GOOD_NAME": ["bad\u{0}value"] })),
458 ] {
459 let mut config = base();
460 config["evals"][0]["assertions"] = json!([{
461 "id": "environment",
462 "type": "command_check",
463 "command": "true",
464 field: value
465 }]);
466
467 let error = validate_evals_config(&config, "evals.json")
468 .unwrap_err()
469 .to_string();
470 assert!(error.contains(field), "{field}: {error}");
471 assert!(error.contains("environment"), "{field}: {error}");
472 }
473 }
474
475 #[test]
476 fn rejects_empty_or_duplicate_command_check_environment_collections() {
477 for (field, value) in [
478 ("env", json!({})),
479 ("matrix", json!({})),
480 ("matrix", json!({ "TZ": [] })),
481 ("matrix", json!({ "TZ": ["UTC", "UTC"] })),
482 ] {
483 let mut config = base();
484 config["evals"][0]["assertions"] = json!([{
485 "id": "environment",
486 "type": "command_check",
487 "command": "true",
488 field: value
489 }]);
490
491 let error = validate_evals_config(&config, "evals.json")
492 .unwrap_err()
493 .to_string();
494 assert!(error.contains(field), "{field}: {error}");
495 }
496 }
497
498 #[test]
499 fn rejects_non_string_command_check_environment_values() {
500 for (field, value) in [
501 ("env", json!({ "CI": 1 })),
502 ("env", json!({ "CI": null })),
503 ("matrix", json!({ "TZ": ["UTC", 1] })),
504 ("matrix", json!({ "TZ": "UTC" })),
505 ] {
506 let mut config = base();
507 config["evals"][0]["assertions"] = json!([{
508 "id": "environment",
509 "type": "command_check",
510 "command": "true",
511 field: value
512 }]);
513
514 let error = validate_evals_config(&config, "evals.json")
515 .unwrap_err()
516 .to_string();
517 assert!(error.contains(field), "{field}: {error}");
518 }
519 }
520
521 #[test]
522 fn accepts_diff_scope_with_either_or_both_thresholds() {
523 for assertion in [
524 json!({
525 "id": "files",
526 "type": "diff_scope",
527 "max_files_touched": 1
528 }),
529 json!({
530 "id": "lines",
531 "type": "diff_scope",
532 "max_lines_changed": 8
533 }),
534 json!({
535 "id": "both",
536 "type": "diff_scope",
537 "max_files_touched": 1,
538 "max_lines_changed": 8
539 }),
540 ] {
541 let mut config = base();
542 config["evals"][0]["assertions"] = json!([assertion]);
543 validate_evals_config(&config, "evals.json").unwrap();
544 }
545 }
546
547 #[test]
548 fn rejects_diff_scope_without_a_threshold() {
549 let mut config = base();
550 config["evals"][0]["assertions"] = json!([{
551 "id": "report-only",
552 "type": "diff_scope"
553 }]);
554 let error = validate_evals_config(&config, "evals.json")
555 .unwrap_err()
556 .to_string();
557 assert!(error.contains("diff_scope"), "{error}");
558 }
559
560 fn with_command_check(files: &[&str], setup_files: &[&str]) -> Value {
561 let mut config = base();
562 config["evals"][0]["files"] = json!(files);
563 config["evals"][0]["assertions"] = json!([{
564 "id": "held-out",
565 "type": "command_check",
566 "setup_files": setup_files,
567 "command": "test -f holdout/test.txt"
568 }]);
569 config
570 }
571
572 #[test]
573 fn rejects_exact_visible_and_setup_file_overlap() {
574 let config = with_command_check(&["holdout/test.txt"], &["holdout/test.txt"]);
575 let err = validate_evals_config(&config, "evals.json")
576 .unwrap_err()
577 .to_string();
578 assert!(err.contains("overlap"), "error was: {err}");
579 assert!(err.contains("holdout/test.txt"), "error was: {err}");
580 }
581
582 #[test]
583 fn rejects_visible_directory_ancestor_of_setup_file() {
584 let config = with_command_check(&["holdout"], &["holdout/test.txt"]);
585 let err = validate_evals_config(&config, "evals.json")
586 .unwrap_err()
587 .to_string();
588 assert!(err.contains("overlap"), "error was: {err}");
589 assert!(err.contains("holdout"), "error was: {err}");
590 }
591
592 #[test]
593 fn rejects_setup_directory_ancestor_of_visible_file() {
594 let config = with_command_check(&["src/main.rs"], &["src"]);
595 let err = validate_evals_config(&config, "evals.json")
596 .unwrap_err()
597 .to_string();
598 assert!(err.contains("overlap"), "error was: {err}");
599 assert!(err.contains("src"), "error was: {err}");
600 }
601
602 #[test]
603 fn rejects_absolute_and_escaping_setup_paths() {
604 for setup in ["/tmp/holdout.txt", "../holdout.txt", "holdout/../../escape"] {
605 let config = with_command_check(&["src/main.rs"], &[setup]);
606 let err = validate_evals_config(&config, "evals.json")
607 .unwrap_err()
608 .to_string();
609 assert!(err.contains("setup_files"), "{setup}: {err}");
610 assert!(err.contains("relative"), "{setup}: {err}");
611 }
612 }
613
614 #[test]
615 fn accepts_disjoint_visible_and_setup_paths() {
616 let config = with_command_check(&["src/main.rs"], &["holdout/test.txt"]);
617 validate_evals_config(&config, "evals.json").unwrap();
618 }
619}