1use std::collections::BTreeSet;
7
8use thiserror::Error;
9
10use crate::compose::{ChannelMembers, Compose};
11
12#[derive(Debug, Error, PartialEq, Eq)]
13pub enum ValidationError {
14 #[error("project `{0}`: duplicate agent id `{1}` in managers and workers")]
15 DuplicateAgent(String, String),
16
17 #[error(
18 "project `{project}`: unknown agent `{agent}` referenced in channel `{channel}` members"
19 )]
20 ChannelUnknownMember {
21 project: String,
22 channel: String,
23 agent: String,
24 },
25
26 #[error("project `{project}`: agent `{agent}` `can_dm` lists unknown agent `{target}`")]
27 DmUnknownTarget {
28 project: String,
29 agent: String,
30 target: String,
31 },
32
33 #[error(
34 "project `{project}`: agent `{agent}` `can_broadcast` lists unknown channel `{channel}`"
35 )]
36 BroadcastUnknownChannel {
37 project: String,
38 agent: String,
39 channel: String,
40 },
41
42 #[error(
43 "project `{project}`: agent `{agent}` has an `interfaces.telegram` block but is not a manager"
44 )]
45 TelegramInboxOnWorker { project: String, agent: String },
46
47 #[error(
48 "worker `{project}:{agent}` declares `reports_to: {target}` but no such manager exists"
49 )]
50 UnknownManager {
51 project: String,
52 agent: String,
53 target: String,
54 },
55
56 #[error("broker type `{0}` not supported (known: sqlite)")]
57 UnknownBroker(String),
58
59 #[error("supervisor type `{0}` not supported (known: tmux, systemd, launchd)")]
60 UnknownSupervisor(String),
61
62 #[error("duplicate project id `{0}`")]
63 DuplicateProject(String),
64
65 #[error(
66 "project id `{0}` has disallowed characters; allowed: ASCII letters, digits, and `.` `_` `-` (no whitespace, shell metacharacters, or control chars; `:` is reserved as the project:agent separator)"
67 )]
68 InvalidProjectId(String),
69
70 #[error(
71 "project `{project}`: agent id `{agent}` has disallowed characters; allowed: ASCII letters, digits, and `.` `_` `-` (no whitespace, shell metacharacters, or control chars; `:` is reserved as the project:agent separator)"
72 )]
73 InvalidAgentId { project: String, agent: String },
74
75 #[error("project `{project}`: agent `{agent}` uses runtime `{runtime}`, which is not built in and not declared in `<root>/runtimes/{runtime}.yaml`")]
76 UnknownRuntime {
77 project: String,
78 agent: String,
79 runtime: String,
80 },
81
82 #[error("supervisor.drain_timeout_secs={0} is unreasonable; expected 0..=600")]
83 DrainTimeoutOutOfRange(u64),
84
85 #[error(
86 "compose schema `version: {got}` is not a valid semver string (expected e.g. `\"2.0.0\"`)"
87 )]
88 SchemaVersionInvalid { got: String },
89
90 #[error(
91 "project `{project}`: agent `{agent}` has a blank `role_prompt` (empty string or empty list)"
92 )]
93 BlankRolePrompt { project: String, agent: String },
94
95 #[error("project `{project}`: agent `{agent}` has a blank `display_name`")]
96 BlankDisplayName { project: String, agent: String },
97
98 #[error("project `{project}`: agent `{agent}` `display_name` is {got} chars (max {max})")]
99 DisplayNameTooLong {
100 project: String,
101 agent: String,
102 got: usize,
103 max: usize,
104 },
105
106 #[error(
107 "project `{project}`: agent `{agent}` declares an MCP server named `team`, which is reserved for the built-in mailbox server"
108 )]
109 ReservedMcpServerName { project: String, agent: String },
110}
111
112pub const DISPLAY_NAME_MAX_CHARS: usize = 64;
120
121pub fn is_valid_id(s: &str) -> bool {
136 !s.is_empty()
137 && s.chars()
138 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
139}
140
141pub fn validate(compose: &Compose) -> Vec<ValidationError> {
142 let mut errs = Vec::new();
143
144 let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
148 let check_runtime = !runtimes.is_empty();
149
150 match compose.global.broker.r#type.as_str() {
151 "sqlite" => {}
152 other => errs.push(ValidationError::UnknownBroker(other.into())),
153 }
154 match compose.global.supervisor.r#type.as_str() {
155 "tmux" | "systemd" | "launchd" => {}
156 other => errs.push(ValidationError::UnknownSupervisor(other.into())),
157 }
158 if compose.global.supervisor.drain_timeout_secs > 600 {
159 errs.push(ValidationError::DrainTimeoutOutOfRange(
160 compose.global.supervisor.drain_timeout_secs,
161 ));
162 }
163
164 if semver::Version::parse(&compose.global.version.value).is_err() {
173 errs.push(ValidationError::SchemaVersionInvalid {
174 got: compose.global.version.value.clone(),
175 });
176 }
177
178 let mut seen_projects = BTreeSet::new();
179 for p in &compose.projects {
180 if !seen_projects.insert(p.project.id.clone()) {
181 errs.push(ValidationError::DuplicateProject(p.project.id.clone()));
182 }
183 if !is_valid_id(&p.project.id) {
188 errs.push(ValidationError::InvalidProjectId(p.project.id.clone()));
189 }
190 for id in p.managers.keys().chain(p.workers.keys()) {
191 if !is_valid_id(id) {
192 errs.push(ValidationError::InvalidAgentId {
193 project: p.project.id.clone(),
194 agent: id.clone(),
195 });
196 }
197 }
198
199 let mgr_ids: BTreeSet<&str> = p.managers.keys().map(|s| s.as_str()).collect();
200 let wrk_ids: BTreeSet<&str> = p.workers.keys().map(|s| s.as_str()).collect();
201 for dup in mgr_ids.intersection(&wrk_ids) {
202 errs.push(ValidationError::DuplicateAgent(
203 p.project.id.clone(),
204 (*dup).to_string(),
205 ));
206 }
207 let all_agents: BTreeSet<&str> = mgr_ids.union(&wrk_ids).copied().collect();
208
209 let channel_names: BTreeSet<&str> = p.channels.iter().map(|c| c.name.as_str()).collect();
211 for ch in &p.channels {
212 if let ChannelMembers::Explicit(members) = &ch.members {
213 for m in members {
214 if !all_agents.contains(m.as_str()) {
215 errs.push(ValidationError::ChannelUnknownMember {
216 project: p.project.id.clone(),
217 channel: ch.name.clone(),
218 agent: m.clone(),
219 });
220 }
221 }
222 }
223 }
224
225 let check_agent = |errs: &mut Vec<ValidationError>,
227 id: &str,
228 a: &crate::compose::Agent,
229 is_manager: bool| {
230 if a.telegram().is_some() && !is_manager {
231 errs.push(ValidationError::TelegramInboxOnWorker {
232 project: p.project.id.clone(),
233 agent: id.into(),
234 });
235 }
236 for t in &a.can_dm {
237 if !all_agents.contains(t.as_str()) {
238 errs.push(ValidationError::DmUnknownTarget {
239 project: p.project.id.clone(),
240 agent: id.into(),
241 target: t.clone(),
242 });
243 }
244 }
245 for c in &a.can_broadcast {
246 if !channel_names.contains(c.as_str()) {
247 errs.push(ValidationError::BroadcastUnknownChannel {
248 project: p.project.id.clone(),
249 agent: id.into(),
250 channel: c.clone(),
251 });
252 }
253 }
254 if let Some(t) = &a.reports_to {
255 if !mgr_ids.contains(t.as_str()) {
256 errs.push(ValidationError::UnknownManager {
257 project: p.project.id.clone(),
258 agent: id.into(),
259 target: t.clone(),
260 });
261 }
262 }
263 if check_runtime && !runtimes.contains_key(a.runtime.as_str()) {
264 errs.push(ValidationError::UnknownRuntime {
265 project: p.project.id.clone(),
266 agent: id.into(),
267 runtime: a.runtime.clone(),
268 });
269 }
270 if let Some(rp) = &a.role_prompt {
271 if rp.is_blank() {
272 errs.push(ValidationError::BlankRolePrompt {
273 project: p.project.id.clone(),
274 agent: id.into(),
275 });
276 }
277 }
278 if let Some(dn) = &a.display_name {
279 let trimmed_len = dn.trim().chars().count();
284 if trimmed_len == 0 {
285 errs.push(ValidationError::BlankDisplayName {
286 project: p.project.id.clone(),
287 agent: id.into(),
288 });
289 } else if dn.chars().count() > DISPLAY_NAME_MAX_CHARS {
290 errs.push(ValidationError::DisplayNameTooLong {
291 project: p.project.id.clone(),
292 agent: id.into(),
293 got: dn.chars().count(),
294 max: DISPLAY_NAME_MAX_CHARS,
295 });
296 }
297 }
298 if a.mcps.contains_key("team") {
303 errs.push(ValidationError::ReservedMcpServerName {
304 project: p.project.id.clone(),
305 agent: id.into(),
306 });
307 }
308 };
309
310 for (id, a) in &p.managers {
311 check_agent(&mut errs, id, a, true);
312 }
313 for (id, a) in &p.workers {
314 check_agent(&mut errs, id, a, false);
315 }
316 }
317
318 errs
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::compose::*;
325 use std::collections::BTreeMap;
326 use std::path::PathBuf;
327
328 fn toy_compose(agent_dm_target: &str) -> Compose {
329 let mut managers = BTreeMap::new();
330 managers.insert(
331 "mgr".into(),
332 Agent {
333 runtime: "claude-code".into(),
334 model: Some("claude-opus-4-8".into()),
335 role_prompt: None,
336 permission_mode: None,
337 autonomy: "low_risk_only".into(),
338 can_dm: vec![agent_dm_target.into()],
339 can_broadcast: vec!["team".into()],
340 reports_to: None,
341 on_rate_limit: None,
342 effort: None,
343 ultracode: false,
344 interfaces: None,
345 display_name: None,
346 hooks: vec![],
347 mcps: Default::default(),
348 subagents: vec![],
349 skills: vec![],
350 },
351 );
352 let mut workers = BTreeMap::new();
353 workers.insert(
354 "dev".into(),
355 Agent {
356 runtime: "claude-code".into(),
357 model: None,
358 role_prompt: None,
359 permission_mode: None,
360 autonomy: "low_risk_only".into(),
361 can_dm: vec!["mgr".into()],
362 can_broadcast: vec!["team".into()],
363 reports_to: Some("mgr".into()),
364 on_rate_limit: None,
365 effort: None,
366 ultracode: false,
367 interfaces: None,
368 display_name: None,
369 hooks: vec![],
370 mcps: Default::default(),
371 subagents: vec![],
372 skills: vec![],
373 },
374 );
375 Compose {
376 root: PathBuf::from("."),
377 global: Global {
378 version: crate::compose::SchemaVersion::new("2.0.0"),
379 broker: Default::default(),
380 supervisor: Default::default(),
381 budget: Default::default(),
382 hitl: Default::default(),
383 rate_limits: Default::default(),
384 interfaces: vec![],
385 projects: vec![],
386 attachments: Default::default(),
387 },
388 projects: vec![Project {
389 version: 2,
390 project: ProjectMeta {
391 id: "hello".into(),
392 name: "Hello".into(),
393 cwd: PathBuf::from("."),
394 },
395 channels: vec![Channel {
396 name: "team".into(),
397 members: ChannelMembers::All("*".into()),
398 }],
399 managers,
400 workers,
401 interfaces: None,
402 }],
403 }
404 }
405
406 #[test]
407 fn clean_compose_validates() {
408 let c = toy_compose("dev");
409 assert_eq!(validate(&c), vec![]);
410 }
411
412 #[test]
413 fn dm_to_unknown_agent_flags() {
414 let c = toy_compose("ghost");
415 let e = validate(&c);
416 assert!(matches!(
417 e.as_slice(),
418 [ValidationError::DmUnknownTarget { .. }]
419 ));
420 }
421
422 #[test]
423 fn unknown_broker_flags() {
424 let mut c = toy_compose("dev");
425 c.global.broker.r#type = "redis".into();
426 assert!(validate(&c)
427 .iter()
428 .any(|e| matches!(e, ValidationError::UnknownBroker(_))));
429 }
430
431 #[test]
432 fn drain_timeout_above_600s_flags() {
433 let mut c = toy_compose("dev");
434 c.global.supervisor.drain_timeout_secs = 86_400;
435 assert!(validate(&c)
436 .iter()
437 .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(86_400))));
438 }
439
440 #[test]
441 fn drain_timeout_zero_is_valid() {
442 let mut c = toy_compose("dev");
443 c.global.supervisor.drain_timeout_secs = 0;
444 assert!(!validate(&c)
445 .iter()
446 .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(_))));
447 }
448
449 #[test]
450 fn empty_role_prompt_list_flags() {
451 let mut c = toy_compose("dev");
452 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
453 Some(crate::compose::RolePrompt::Multiple(vec![]));
454 assert!(validate(&c)
455 .iter()
456 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
457 }
458
459 #[test]
460 fn empty_role_prompt_string_flags() {
461 let mut c = toy_compose("dev");
466 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
467 Some(crate::compose::RolePrompt::Single(PathBuf::from("")));
468 assert!(validate(&c)
469 .iter()
470 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
471 }
472
473 #[test]
474 fn single_role_prompt_validates() {
475 let mut c = toy_compose("dev");
476 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
477 crate::compose::RolePrompt::Single(PathBuf::from("roles/mgr.md")),
478 );
479 assert!(!validate(&c)
480 .iter()
481 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
482 }
483
484 #[test]
485 fn populated_role_prompt_list_validates() {
486 let mut c = toy_compose("dev");
487 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
488 crate::compose::RolePrompt::Multiple(vec![PathBuf::from("roles/mgr.md")]),
489 );
490 assert!(!validate(&c)
491 .iter()
492 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
493 }
494
495 #[test]
496 fn blank_display_name_flags() {
497 let mut c = toy_compose("dev");
498 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(String::new());
499 assert!(validate(&c)
500 .iter()
501 .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
502 }
503
504 #[test]
505 fn declared_mcp_server_named_team_flags() {
506 let mut c = toy_compose("dev");
509 let mut mcps = std::collections::BTreeMap::new();
510 mcps.insert(
511 "team".into(),
512 crate::compose::McpServer {
513 command: "evil".into(),
514 args: vec![],
515 env: Default::default(),
516 },
517 );
518 c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
519 assert!(validate(&c)
520 .iter()
521 .any(|e| matches!(e, ValidationError::ReservedMcpServerName { .. })));
522 }
523
524 #[test]
525 fn declared_mcp_server_with_normal_name_validates() {
526 let mut c = toy_compose("dev");
528 let mut mcps = std::collections::BTreeMap::new();
529 mcps.insert(
530 "github".into(),
531 crate::compose::McpServer {
532 command: "npx".into(),
533 args: vec![],
534 env: Default::default(),
535 },
536 );
537 c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
538 assert!(!validate(&c)
539 .iter()
540 .any(|e| matches!(e, ValidationError::ReservedMcpServerName { .. })));
541 }
542
543 #[test]
544 fn display_name_at_max_length_validates() {
545 let mut c = toy_compose("dev");
546 let exactly_max = "x".repeat(DISPLAY_NAME_MAX_CHARS);
547 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(exactly_max);
548 assert!(!validate(&c).iter().any(|e| matches!(
549 e,
550 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
551 )));
552 }
553
554 #[test]
555 fn display_name_above_max_length_flags() {
556 let mut c = toy_compose("dev");
557 let too_long = "x".repeat(DISPLAY_NAME_MAX_CHARS + 1);
558 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(too_long);
559 assert!(validate(&c)
560 .iter()
561 .any(|e| matches!(e, ValidationError::DisplayNameTooLong { .. })));
562 }
563
564 #[test]
565 fn display_name_counts_chars_not_bytes() {
566 let mut c = toy_compose("dev");
571 let sixty_four_crabs = "🦀".repeat(DISPLAY_NAME_MAX_CHARS);
572 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(sixty_four_crabs);
573 assert!(!validate(&c).iter().any(|e| matches!(
574 e,
575 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
576 )));
577 }
578
579 #[test]
580 fn whitespace_only_display_name_flags_blank() {
581 let mut c = toy_compose("dev");
585 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(" ".into());
586 assert!(validate(&c)
587 .iter()
588 .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
589 }
590
591 #[test]
592 fn populated_display_name_validates() {
593 let mut c = toy_compose("dev");
594 c.projects[0].managers.get_mut("mgr").unwrap().display_name =
595 Some("Sage (Visionary)".into());
596 assert!(!validate(&c).iter().any(|e| matches!(
597 e,
598 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
599 )));
600 }
601
602 #[test]
605 fn is_valid_id_accepts_existing_id_shapes() {
606 for ok in [
610 "teamctl",
611 "ops",
612 "nico",
613 "eng_lead",
614 "pr-22-review",
615 "blog-site",
616 "my.team",
617 "a1",
618 "x-2.0",
619 "a",
620 "0",
621 "A",
622 "_",
623 "-",
624 ".",
625 ] {
626 assert!(is_valid_id(ok), "must accept conformant id `{ok}`");
627 }
628 }
629
630 #[test]
631 fn is_valid_id_rejects_shell_metacharacter_class() {
632 for bad in [
636 "evil; rm",
637 "proj$(id)",
638 "with space",
639 "back`ticks`",
640 "p|ipe",
641 "p&",
642 "p*g",
643 "p?g",
644 "p~e",
645 "p!g",
646 "p#g",
647 "p'q",
648 "p\"q",
649 "p\\g",
650 "p<g",
651 "p>g",
652 "p(g",
653 "p)g",
654 "p\tg",
655 "p\ng",
656 ] {
657 assert!(!is_valid_id(bad), "must reject `{bad:?}`");
658 }
659 }
660
661 #[test]
662 fn is_valid_id_rejects_colon_as_reserved_separator() {
663 assert!(!is_valid_id("p:rj"));
668 assert!(!is_valid_id(":"));
669 assert!(!is_valid_id("a:"));
670 assert!(!is_valid_id(":a"));
671 }
672
673 #[test]
674 fn is_valid_id_rejects_empty_and_control_chars() {
675 assert!(!is_valid_id(""));
676 assert!(!is_valid_id("\0"));
677 assert!(!is_valid_id("p\x07q"));
678 }
679
680 #[test]
681 fn is_valid_id_rejects_non_ascii() {
682 assert!(!is_valid_id("crab🦀"));
685 assert!(!is_valid_id("café"));
686 }
687
688 #[test]
689 fn clean_compose_passes_id_charset() {
690 let c = toy_compose("dev");
693 let errs = validate(&c);
694 assert!(
695 !errs.iter().any(|e| matches!(
696 e,
697 ValidationError::InvalidProjectId(_) | ValidationError::InvalidAgentId { .. }
698 )),
699 "clean compose unexpectedly flagged for id charset: {errs:?}",
700 );
701 }
702
703 #[test]
704 fn project_id_with_shell_metacharacters_flags() {
705 let mut c = toy_compose("dev");
711 c.projects[0].project.id = "evil; rm -rf ~".into();
712 let errs = validate(&c);
713 assert!(
714 errs.iter().any(|e| matches!(
715 e,
716 ValidationError::InvalidProjectId(s) if s == "evil; rm -rf ~"
717 )),
718 "expected InvalidProjectId, got {errs:?}",
719 );
720 }
721
722 #[test]
723 fn manager_id_with_shell_metacharacters_flags() {
724 let mut c = toy_compose("dev");
728 let bad = "$(id)";
729 let mgr = c.projects[0].managers.remove("mgr").unwrap();
730 c.projects[0].managers.insert(bad.into(), mgr);
731 let errs = validate(&c);
732 assert!(
733 errs.iter().any(|e| matches!(
734 e,
735 ValidationError::InvalidAgentId { project, agent }
736 if project == "hello" && agent == bad
737 )),
738 "expected InvalidAgentId for manager, got {errs:?}",
739 );
740 }
741
742 #[test]
743 fn worker_id_with_shell_metacharacters_flags() {
744 let mut c = toy_compose("dev");
746 let bad = "rogue|pipe";
747 let wkr = c.projects[0].workers.remove("dev").unwrap();
748 c.projects[0].workers.insert(bad.into(), wkr);
749 let errs = validate(&c);
752 assert!(
753 errs.iter().any(|e| matches!(
754 e,
755 ValidationError::InvalidAgentId { project, agent }
756 if project == "hello" && agent == bad
757 )),
758 "expected InvalidAgentId for worker, got {errs:?}",
759 );
760 }
761
762 #[test]
763 fn project_id_with_reserved_colon_flags() {
764 let mut c = toy_compose("dev");
768 c.projects[0].project.id = "foo:bar".into();
769 let errs = validate(&c);
770 assert!(
771 errs.iter()
772 .any(|e| matches!(e, ValidationError::InvalidProjectId(s) if s == "foo:bar")),
773 "expected InvalidProjectId on colon, got {errs:?}",
774 );
775 }
776
777 #[test]
782 fn valid_semver_string_validates() {
783 let c = toy_compose("dev");
785 assert!(
786 !validate(&c)
787 .iter()
788 .any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
789 "canonical version `2.0.0` must validate"
790 );
791 }
792
793 #[test]
794 fn malformed_semver_string_flags() {
795 let mut c = toy_compose("dev");
796 c.global.version = crate::compose::SchemaVersion::new("abc");
797 let errs = validate(&c);
798 assert!(
799 errs.iter().any(|e| matches!(
800 e,
801 ValidationError::SchemaVersionInvalid { got } if got == "abc"
802 )),
803 "non-semver string must surface SchemaVersionInvalid; got {errs:?}"
804 );
805 }
806
807 #[test]
808 fn bare_two_string_flags_too() {
809 let mut c = toy_compose("dev");
812 c.global.version = crate::compose::SchemaVersion::new("2");
813 assert!(
814 validate(&c).iter().any(|e| matches!(
815 e,
816 ValidationError::SchemaVersionInvalid { got } if got == "2"
817 )),
818 "bare-2-string must NOT pass the semver shape check"
819 );
820 }
821
822 #[test]
823 fn semver_with_prerelease_and_build_metadata_validates() {
824 for ok in [
829 "1.0.0",
830 "2.3.4",
831 "2.0.0-alpha",
832 "1.0.0+build.5",
833 "2.0.0-rc.1+build.7",
834 ] {
835 let mut c = toy_compose("dev");
836 c.global.version = crate::compose::SchemaVersion::new(ok);
837 assert!(
838 !validate(&c)
839 .iter()
840 .any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
841 "semver `{ok}` must validate"
842 );
843 }
844 }
845}