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 interfaces: None,
344 display_name: None,
345 hooks: vec![],
346 mcps: Default::default(),
347 subagents: vec![],
348 skills: vec![],
349 },
350 );
351 let mut workers = BTreeMap::new();
352 workers.insert(
353 "dev".into(),
354 Agent {
355 runtime: "claude-code".into(),
356 model: None,
357 role_prompt: None,
358 permission_mode: None,
359 autonomy: "low_risk_only".into(),
360 can_dm: vec!["mgr".into()],
361 can_broadcast: vec!["team".into()],
362 reports_to: Some("mgr".into()),
363 on_rate_limit: None,
364 effort: None,
365 interfaces: None,
366 display_name: None,
367 hooks: vec![],
368 mcps: Default::default(),
369 subagents: vec![],
370 skills: vec![],
371 },
372 );
373 Compose {
374 root: PathBuf::from("."),
375 global: Global {
376 version: crate::compose::SchemaVersion::new("2.0.0"),
377 broker: Default::default(),
378 supervisor: Default::default(),
379 budget: Default::default(),
380 hitl: Default::default(),
381 rate_limits: Default::default(),
382 interfaces: vec![],
383 projects: vec![],
384 attachments: Default::default(),
385 },
386 projects: vec![Project {
387 version: 2,
388 project: ProjectMeta {
389 id: "hello".into(),
390 name: "Hello".into(),
391 cwd: PathBuf::from("."),
392 },
393 channels: vec![Channel {
394 name: "team".into(),
395 members: ChannelMembers::All("*".into()),
396 }],
397 managers,
398 workers,
399 interfaces: None,
400 }],
401 }
402 }
403
404 #[test]
405 fn clean_compose_validates() {
406 let c = toy_compose("dev");
407 assert_eq!(validate(&c), vec![]);
408 }
409
410 #[test]
411 fn dm_to_unknown_agent_flags() {
412 let c = toy_compose("ghost");
413 let e = validate(&c);
414 assert!(matches!(
415 e.as_slice(),
416 [ValidationError::DmUnknownTarget { .. }]
417 ));
418 }
419
420 #[test]
421 fn unknown_broker_flags() {
422 let mut c = toy_compose("dev");
423 c.global.broker.r#type = "redis".into();
424 assert!(validate(&c)
425 .iter()
426 .any(|e| matches!(e, ValidationError::UnknownBroker(_))));
427 }
428
429 #[test]
430 fn drain_timeout_above_600s_flags() {
431 let mut c = toy_compose("dev");
432 c.global.supervisor.drain_timeout_secs = 86_400;
433 assert!(validate(&c)
434 .iter()
435 .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(86_400))));
436 }
437
438 #[test]
439 fn drain_timeout_zero_is_valid() {
440 let mut c = toy_compose("dev");
441 c.global.supervisor.drain_timeout_secs = 0;
442 assert!(!validate(&c)
443 .iter()
444 .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(_))));
445 }
446
447 #[test]
448 fn empty_role_prompt_list_flags() {
449 let mut c = toy_compose("dev");
450 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
451 Some(crate::compose::RolePrompt::Multiple(vec![]));
452 assert!(validate(&c)
453 .iter()
454 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
455 }
456
457 #[test]
458 fn empty_role_prompt_string_flags() {
459 let mut c = toy_compose("dev");
464 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
465 Some(crate::compose::RolePrompt::Single(PathBuf::from("")));
466 assert!(validate(&c)
467 .iter()
468 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
469 }
470
471 #[test]
472 fn single_role_prompt_validates() {
473 let mut c = toy_compose("dev");
474 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
475 crate::compose::RolePrompt::Single(PathBuf::from("roles/mgr.md")),
476 );
477 assert!(!validate(&c)
478 .iter()
479 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
480 }
481
482 #[test]
483 fn populated_role_prompt_list_validates() {
484 let mut c = toy_compose("dev");
485 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
486 crate::compose::RolePrompt::Multiple(vec![PathBuf::from("roles/mgr.md")]),
487 );
488 assert!(!validate(&c)
489 .iter()
490 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
491 }
492
493 #[test]
494 fn blank_display_name_flags() {
495 let mut c = toy_compose("dev");
496 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(String::new());
497 assert!(validate(&c)
498 .iter()
499 .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
500 }
501
502 #[test]
503 fn declared_mcp_server_named_team_flags() {
504 let mut c = toy_compose("dev");
507 let mut mcps = std::collections::BTreeMap::new();
508 mcps.insert(
509 "team".into(),
510 crate::compose::McpServer {
511 command: "evil".into(),
512 args: vec![],
513 env: Default::default(),
514 },
515 );
516 c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
517 assert!(validate(&c)
518 .iter()
519 .any(|e| matches!(e, ValidationError::ReservedMcpServerName { .. })));
520 }
521
522 #[test]
523 fn declared_mcp_server_with_normal_name_validates() {
524 let mut c = toy_compose("dev");
526 let mut mcps = std::collections::BTreeMap::new();
527 mcps.insert(
528 "github".into(),
529 crate::compose::McpServer {
530 command: "npx".into(),
531 args: vec![],
532 env: Default::default(),
533 },
534 );
535 c.projects[0].managers.get_mut("mgr").unwrap().mcps = mcps;
536 assert!(!validate(&c)
537 .iter()
538 .any(|e| matches!(e, ValidationError::ReservedMcpServerName { .. })));
539 }
540
541 #[test]
542 fn display_name_at_max_length_validates() {
543 let mut c = toy_compose("dev");
544 let exactly_max = "x".repeat(DISPLAY_NAME_MAX_CHARS);
545 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(exactly_max);
546 assert!(!validate(&c).iter().any(|e| matches!(
547 e,
548 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
549 )));
550 }
551
552 #[test]
553 fn display_name_above_max_length_flags() {
554 let mut c = toy_compose("dev");
555 let too_long = "x".repeat(DISPLAY_NAME_MAX_CHARS + 1);
556 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(too_long);
557 assert!(validate(&c)
558 .iter()
559 .any(|e| matches!(e, ValidationError::DisplayNameTooLong { .. })));
560 }
561
562 #[test]
563 fn display_name_counts_chars_not_bytes() {
564 let mut c = toy_compose("dev");
569 let sixty_four_crabs = "🦀".repeat(DISPLAY_NAME_MAX_CHARS);
570 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(sixty_four_crabs);
571 assert!(!validate(&c).iter().any(|e| matches!(
572 e,
573 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
574 )));
575 }
576
577 #[test]
578 fn whitespace_only_display_name_flags_blank() {
579 let mut c = toy_compose("dev");
583 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(" ".into());
584 assert!(validate(&c)
585 .iter()
586 .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
587 }
588
589 #[test]
590 fn populated_display_name_validates() {
591 let mut c = toy_compose("dev");
592 c.projects[0].managers.get_mut("mgr").unwrap().display_name =
593 Some("Sage (Visionary)".into());
594 assert!(!validate(&c).iter().any(|e| matches!(
595 e,
596 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
597 )));
598 }
599
600 #[test]
603 fn is_valid_id_accepts_existing_id_shapes() {
604 for ok in [
608 "teamctl",
609 "ops",
610 "nico",
611 "eng_lead",
612 "pr-22-review",
613 "blog-site",
614 "my.team",
615 "a1",
616 "x-2.0",
617 "a",
618 "0",
619 "A",
620 "_",
621 "-",
622 ".",
623 ] {
624 assert!(is_valid_id(ok), "must accept conformant id `{ok}`");
625 }
626 }
627
628 #[test]
629 fn is_valid_id_rejects_shell_metacharacter_class() {
630 for bad in [
634 "evil; rm",
635 "proj$(id)",
636 "with space",
637 "back`ticks`",
638 "p|ipe",
639 "p&",
640 "p*g",
641 "p?g",
642 "p~e",
643 "p!g",
644 "p#g",
645 "p'q",
646 "p\"q",
647 "p\\g",
648 "p<g",
649 "p>g",
650 "p(g",
651 "p)g",
652 "p\tg",
653 "p\ng",
654 ] {
655 assert!(!is_valid_id(bad), "must reject `{bad:?}`");
656 }
657 }
658
659 #[test]
660 fn is_valid_id_rejects_colon_as_reserved_separator() {
661 assert!(!is_valid_id("p:rj"));
666 assert!(!is_valid_id(":"));
667 assert!(!is_valid_id("a:"));
668 assert!(!is_valid_id(":a"));
669 }
670
671 #[test]
672 fn is_valid_id_rejects_empty_and_control_chars() {
673 assert!(!is_valid_id(""));
674 assert!(!is_valid_id("\0"));
675 assert!(!is_valid_id("p\x07q"));
676 }
677
678 #[test]
679 fn is_valid_id_rejects_non_ascii() {
680 assert!(!is_valid_id("crab🦀"));
683 assert!(!is_valid_id("café"));
684 }
685
686 #[test]
687 fn clean_compose_passes_id_charset() {
688 let c = toy_compose("dev");
691 let errs = validate(&c);
692 assert!(
693 !errs.iter().any(|e| matches!(
694 e,
695 ValidationError::InvalidProjectId(_) | ValidationError::InvalidAgentId { .. }
696 )),
697 "clean compose unexpectedly flagged for id charset: {errs:?}",
698 );
699 }
700
701 #[test]
702 fn project_id_with_shell_metacharacters_flags() {
703 let mut c = toy_compose("dev");
709 c.projects[0].project.id = "evil; rm -rf ~".into();
710 let errs = validate(&c);
711 assert!(
712 errs.iter().any(|e| matches!(
713 e,
714 ValidationError::InvalidProjectId(s) if s == "evil; rm -rf ~"
715 )),
716 "expected InvalidProjectId, got {errs:?}",
717 );
718 }
719
720 #[test]
721 fn manager_id_with_shell_metacharacters_flags() {
722 let mut c = toy_compose("dev");
726 let bad = "$(id)";
727 let mgr = c.projects[0].managers.remove("mgr").unwrap();
728 c.projects[0].managers.insert(bad.into(), mgr);
729 let errs = validate(&c);
730 assert!(
731 errs.iter().any(|e| matches!(
732 e,
733 ValidationError::InvalidAgentId { project, agent }
734 if project == "hello" && agent == bad
735 )),
736 "expected InvalidAgentId for manager, got {errs:?}",
737 );
738 }
739
740 #[test]
741 fn worker_id_with_shell_metacharacters_flags() {
742 let mut c = toy_compose("dev");
744 let bad = "rogue|pipe";
745 let wkr = c.projects[0].workers.remove("dev").unwrap();
746 c.projects[0].workers.insert(bad.into(), wkr);
747 let errs = validate(&c);
750 assert!(
751 errs.iter().any(|e| matches!(
752 e,
753 ValidationError::InvalidAgentId { project, agent }
754 if project == "hello" && agent == bad
755 )),
756 "expected InvalidAgentId for worker, got {errs:?}",
757 );
758 }
759
760 #[test]
761 fn project_id_with_reserved_colon_flags() {
762 let mut c = toy_compose("dev");
766 c.projects[0].project.id = "foo:bar".into();
767 let errs = validate(&c);
768 assert!(
769 errs.iter()
770 .any(|e| matches!(e, ValidationError::InvalidProjectId(s) if s == "foo:bar")),
771 "expected InvalidProjectId on colon, got {errs:?}",
772 );
773 }
774
775 #[test]
780 fn valid_semver_string_validates() {
781 let c = toy_compose("dev");
783 assert!(
784 !validate(&c)
785 .iter()
786 .any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
787 "canonical version `2.0.0` must validate"
788 );
789 }
790
791 #[test]
792 fn malformed_semver_string_flags() {
793 let mut c = toy_compose("dev");
794 c.global.version = crate::compose::SchemaVersion::new("abc");
795 let errs = validate(&c);
796 assert!(
797 errs.iter().any(|e| matches!(
798 e,
799 ValidationError::SchemaVersionInvalid { got } if got == "abc"
800 )),
801 "non-semver string must surface SchemaVersionInvalid; got {errs:?}"
802 );
803 }
804
805 #[test]
806 fn bare_two_string_flags_too() {
807 let mut c = toy_compose("dev");
810 c.global.version = crate::compose::SchemaVersion::new("2");
811 assert!(
812 validate(&c).iter().any(|e| matches!(
813 e,
814 ValidationError::SchemaVersionInvalid { got } if got == "2"
815 )),
816 "bare-2-string must NOT pass the semver shape check"
817 );
818 }
819
820 #[test]
821 fn semver_with_prerelease_and_build_metadata_validates() {
822 for ok in [
827 "1.0.0",
828 "2.3.4",
829 "2.0.0-alpha",
830 "1.0.0+build.5",
831 "2.0.0-rc.1+build.7",
832 ] {
833 let mut c = toy_compose("dev");
834 c.global.version = crate::compose::SchemaVersion::new(ok);
835 assert!(
836 !validate(&c)
837 .iter()
838 .any(|e| matches!(e, ValidationError::SchemaVersionInvalid { .. })),
839 "semver `{ok}` must validate"
840 );
841 }
842 }
843}