1use std::collections::HashSet;
5use std::path::{Path, PathBuf};
6use std::sync::LazyLock;
7
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use tempfile::NamedTempFile;
11
12use super::error::SubAgentError;
13use super::hooks::SubagentHooks;
14
15pub use zeph_config::{MemoryScope, ModelSpec, PermissionMode, SkillFilter, ToolPolicy};
16
17pub(super) static AGENT_NAME_RE: LazyLock<Regex> =
20 LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$").unwrap());
21
22pub fn is_valid_agent_name(name: &str) -> bool {
24 AGENT_NAME_RE.is_match(name)
25}
26
27const MAX_DEF_SIZE: usize = 256 * 1024;
31
32const MAX_ENTRIES_PER_DIR: usize = 100;
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct SubAgentDef {
42 pub name: String,
43 pub description: String,
44 pub model: Option<ModelSpec>,
45 pub tools: ToolPolicy,
46 pub disallowed_tools: Vec<String>,
60 pub permissions: SubAgentPermissions,
61 pub skills: SkillFilter,
62 pub system_prompt: String,
63 pub hooks: SubagentHooks,
68 pub memory: Option<MemoryScope>,
71 #[serde(skip)]
77 pub source: Option<String>,
78 #[serde(skip)]
82 pub file_path: Option<PathBuf>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct SubAgentPermissions {
87 pub secrets: Vec<String>,
88 pub max_turns: u32,
89 pub background: bool,
90 pub timeout_secs: u64,
91 pub ttl_secs: u64,
92 pub permission_mode: PermissionMode,
93}
94
95impl Default for SubAgentPermissions {
96 fn default() -> Self {
97 Self {
98 secrets: Vec::new(),
99 max_turns: 20,
100 background: false,
101 timeout_secs: 600,
102 ttl_secs: 300,
103 permission_mode: PermissionMode::Default,
104 }
105 }
106}
107
108#[derive(Deserialize)]
113#[serde(deny_unknown_fields)]
114struct RawSubAgentDef {
115 name: String,
116 description: String,
117 model: Option<ModelSpec>,
118 #[serde(default)]
119 tools: RawToolPolicy,
120 #[serde(default)]
121 permissions: RawPermissions,
122 #[serde(default)]
123 skills: RawSkillFilter,
124 #[serde(default)]
125 hooks: SubagentHooks,
126 #[serde(default)]
127 memory: Option<MemoryScope>,
128}
129
130#[derive(Default, Deserialize)]
137struct RawToolPolicy {
138 allow: Option<Vec<String>>,
139 deny: Option<Vec<String>>,
140 #[serde(default)]
143 except: Vec<String>,
144}
145
146#[derive(Deserialize)]
147struct RawPermissions {
148 #[serde(default)]
149 secrets: Vec<String>,
150 #[serde(default = "default_max_turns")]
151 max_turns: u32,
152 #[serde(default)]
153 background: bool,
154 #[serde(default = "default_timeout")]
155 timeout_secs: u64,
156 #[serde(default = "default_ttl")]
157 ttl_secs: u64,
158 #[serde(default)]
159 permission_mode: PermissionMode,
160}
161
162impl Default for RawPermissions {
163 fn default() -> Self {
164 Self {
165 secrets: Vec::new(),
166 max_turns: default_max_turns(),
167 background: false,
168 timeout_secs: default_timeout(),
169 ttl_secs: default_ttl(),
170 permission_mode: PermissionMode::Default,
171 }
172 }
173}
174
175#[derive(Default, Deserialize)]
176struct RawSkillFilter {
177 #[serde(default)]
178 include: Vec<String>,
179 #[serde(default)]
180 exclude: Vec<String>,
181}
182
183fn default_max_turns() -> u32 {
184 20
185}
186fn default_timeout() -> u64 {
187 600
188}
189fn default_ttl() -> u64 {
190 300
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196enum FrontmatterFormat {
197 Yaml,
198 Toml,
199}
200
201fn split_frontmatter<'a>(
221 content: &'a str,
222 path: &str,
223) -> Result<(&'a str, &'a str, FrontmatterFormat), SubAgentError> {
224 let make_err = |reason: &str| SubAgentError::Parse {
225 path: path.to_owned(),
226 reason: reason.to_owned(),
227 };
228
229 if let Some(rest) = content
230 .strip_prefix("---")
231 .and_then(|s| s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n")))
232 {
233 let (yaml_str, after) = rest
238 .split_once("\n---")
239 .ok_or_else(|| make_err("missing closing `---` delimiter for YAML frontmatter"))?;
240 let body = after
241 .strip_prefix('\n')
242 .or_else(|| after.strip_prefix("\r\n"))
243 .unwrap_or(after);
244 return Ok((yaml_str, body, FrontmatterFormat::Yaml));
245 }
246
247 if let Some(rest) = content
248 .strip_prefix("+++")
249 .and_then(|s| s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n")))
250 {
251 let (toml_str, after) = rest
253 .split_once("\n+++")
254 .ok_or_else(|| make_err("missing closing `+++` delimiter for TOML frontmatter"))?;
255 let body = after
256 .strip_prefix('\n')
257 .or_else(|| after.strip_prefix("\r\n"))
258 .unwrap_or(after);
259 return Ok((toml_str, body, FrontmatterFormat::Toml));
260 }
261
262 Err(make_err(
263 "missing frontmatter delimiters: expected `---` (YAML) or `+++` (TOML, deprecated)",
264 ))
265}
266
267impl SubAgentDef {
268 pub fn parse(content: &str) -> Result<Self, SubAgentError> {
299 Self::parse_with_path(content, "<unknown>")
300 }
301
302 fn parse_with_path(content: &str, path: &str) -> Result<Self, SubAgentError> {
303 let (frontmatter_str, body, format) = split_frontmatter(content, path)?;
304
305 let raw: RawSubAgentDef = match format {
306 FrontmatterFormat::Yaml => {
307 let yaml_normalized;
309 let yaml_str = if frontmatter_str.contains('\r') {
310 yaml_normalized = frontmatter_str.replace("\r\n", "\n").replace('\r', "\n");
311 &yaml_normalized
312 } else {
313 frontmatter_str
314 };
315 serde_norway::from_str(yaml_str).map_err(|e| SubAgentError::Parse {
316 path: path.to_owned(),
317 reason: e.to_string(),
318 })?
319 }
320 FrontmatterFormat::Toml => {
321 tracing::warn!(
322 path,
323 "sub-agent definition uses deprecated +++ TOML frontmatter, migrate to --- YAML"
324 );
325 let toml_normalized;
327 let toml_str = if frontmatter_str.contains('\r') {
328 toml_normalized = frontmatter_str.replace("\r\n", "\n").replace('\r', "\n");
329 &toml_normalized
330 } else {
331 frontmatter_str
332 };
333 toml::from_str(toml_str).map_err(|e| SubAgentError::Parse {
334 path: path.to_owned(),
335 reason: e.to_string(),
336 })?
337 }
338 };
339
340 if raw.name.trim().is_empty() {
341 return Err(SubAgentError::Invalid("name must not be empty".into()));
342 }
343 if raw.description.trim().is_empty() {
344 return Err(SubAgentError::Invalid(
345 "description must not be empty".into(),
346 ));
347 }
348 if !AGENT_NAME_RE.is_match(&raw.name) {
351 return Err(SubAgentError::Invalid(format!(
352 "name '{}' is invalid: must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{{0,63}}$ \
353 (ASCII only, no spaces or special characters)",
354 raw.name
355 )));
356 }
357 if raw
358 .description
359 .chars()
360 .any(|c| (c < '\x20' && c != '\t') || c == '\x7F')
361 {
362 return Err(SubAgentError::Invalid(
363 "description must not contain control characters".into(),
364 ));
365 }
366
367 let tools = match (raw.tools.allow, raw.tools.deny) {
368 (None, None) => ToolPolicy::InheritAll,
369 (Some(list), None) => ToolPolicy::AllowList(list),
370 (None, Some(list)) => ToolPolicy::DenyList(list),
371 (Some(_), Some(_)) => {
372 return Err(SubAgentError::Invalid(
373 "tools.allow and tools.deny are mutually exclusive".into(),
374 ));
375 }
376 };
377
378 let disallowed_tools = raw.tools.except;
379
380 let p = raw.permissions;
381 if p.permission_mode == PermissionMode::BypassPermissions {
382 tracing::warn!(
383 name = %raw.name,
384 "sub-agent definition uses bypass_permissions mode — grants unrestricted tool access"
385 );
386 }
387 Ok(Self {
388 name: raw.name,
389 description: raw.description,
390 model: raw.model,
391 tools,
392 disallowed_tools,
393 permissions: SubAgentPermissions {
394 secrets: p.secrets,
395 max_turns: p.max_turns,
396 background: p.background,
397 timeout_secs: p.timeout_secs,
398 ttl_secs: p.ttl_secs,
399 permission_mode: p.permission_mode,
400 },
401 skills: SkillFilter {
402 include: raw.skills.include,
403 exclude: raw.skills.exclude,
404 },
405 hooks: raw.hooks,
406 memory: raw.memory,
407 system_prompt: body.trim().to_owned(),
408 source: None,
409 file_path: None,
410 })
411 }
412
413 pub fn load(path: &Path) -> Result<Self, SubAgentError> {
423 Self::load_with_boundary(path, None, None)
424 }
425
426 pub(crate) fn load_with_boundary(
428 path: &Path,
429 boundary: Option<&Path>,
430 scope: Option<&str>,
431 ) -> Result<Self, SubAgentError> {
432 let path_str = path.display().to_string();
433
434 let canonical = std::fs::canonicalize(path).map_err(|e| SubAgentError::Parse {
436 path: path_str.clone(),
437 reason: format!("cannot resolve path: {e}"),
438 })?;
439
440 if let Some(boundary) = boundary
442 && !canonical.starts_with(boundary)
443 {
444 return Err(SubAgentError::Parse {
445 path: path_str.clone(),
446 reason: format!(
447 "definition file escapes allowed directory boundary ({})",
448 boundary.display()
449 ),
450 });
451 }
452
453 let content = std::fs::read_to_string(&canonical).map_err(|e| SubAgentError::Parse {
454 path: path_str.clone(),
455 reason: e.to_string(),
456 })?;
457 if content.len() > MAX_DEF_SIZE {
458 return Err(SubAgentError::Parse {
459 path: path_str.clone(),
460 reason: format!(
461 "definition file exceeds maximum size of {} KiB",
462 MAX_DEF_SIZE / 1024
463 ),
464 });
465 }
466 let mut def = Self::parse_with_path(&content, &path_str)?;
467
468 if scope == Some("user") {
472 if !def.hooks.pre_tool_use.is_empty() || !def.hooks.post_tool_use.is_empty() {
473 tracing::warn!(
474 path = %path_str,
475 "user-level agent definition contains hooks — stripping for security"
476 );
477 }
478 def.hooks = SubagentHooks::default();
479 }
480
481 let filename = path
483 .file_name()
484 .and_then(|f| f.to_str())
485 .unwrap_or("<unknown>");
486 def.source = Some(if let Some(scope) = scope {
487 format!("{scope}/{filename}")
488 } else {
489 filename.to_owned()
490 });
491 def.file_path = Some(canonical);
493
494 Ok(def)
495 }
496
497 pub fn load_all(paths: &[PathBuf]) -> Result<Vec<Self>, SubAgentError> {
510 Self::load_all_with_sources(paths, &[], None, &[])
511 }
512
513 pub fn load_all_with_sources(
523 ordered_paths: &[PathBuf],
524 cli_agents: &[PathBuf],
525 config_user_dir: Option<&PathBuf>,
526 extra_dirs: &[PathBuf],
527 ) -> Result<Vec<Self>, SubAgentError> {
528 let mut seen: HashSet<String> = HashSet::new();
529 let mut result = Vec::new();
530
531 for path in ordered_paths {
532 if path.is_file() {
533 let is_cli = cli_agents.iter().any(|c| c == path);
537 match Self::load_with_boundary(path, None, Some("cli")) {
538 Ok(def) => {
539 if seen.contains(&def.name) {
540 tracing::debug!(
541 name = %def.name,
542 path = %path.display(),
543 "skipping duplicate sub-agent definition"
544 );
545 } else {
546 seen.insert(def.name.clone());
547 result.push(def);
548 }
549 }
550 Err(e) if is_cli => return Err(e),
551 Err(e) => {
552 tracing::warn!(path = %path.display(), error = %e, "skipping malformed agent definition");
553 }
554 }
555 continue;
556 }
557
558 let Ok(read_dir) = std::fs::read_dir(path) else {
559 continue; };
561
562 let is_cli_dir = cli_agents.iter().any(|c| c == path);
566 let boundary = if is_cli_dir {
567 None
568 } else {
569 std::fs::canonicalize(path).ok()
573 };
574
575 let scope = super::resolve::scope_label(path, cli_agents, config_user_dir, extra_dirs);
576 let is_cli_scope = is_cli_dir;
577
578 let mut entries: Vec<PathBuf> = read_dir
579 .filter_map(std::result::Result::ok)
580 .map(|e| e.path())
581 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
582 .collect();
583
584 entries.sort(); if entries.len() > MAX_ENTRIES_PER_DIR {
587 tracing::warn!(
588 dir = %path.display(),
589 count = entries.len(),
590 cap = MAX_ENTRIES_PER_DIR,
591 "agent directory exceeds entry cap; processing only first {MAX_ENTRIES_PER_DIR} files"
592 );
593 entries.truncate(MAX_ENTRIES_PER_DIR);
594 }
595
596 for entry_path in entries {
597 let load_result =
598 Self::load_with_boundary(&entry_path, boundary.as_deref(), Some(scope));
599
600 let def = match load_result {
601 Ok(d) => d,
602 Err(e) if is_cli_scope => return Err(e),
603 Err(e) => {
604 tracing::warn!(
605 path = %entry_path.display(),
606 error = %e,
607 "skipping malformed agent definition"
608 );
609 continue;
610 }
611 };
612
613 if seen.contains(&def.name) {
614 tracing::debug!(
615 name = %def.name,
616 path = %entry_path.display(),
617 "skipping duplicate sub-agent definition (shadowed by higher-priority path)"
618 );
619 continue;
620 }
621 seen.insert(def.name.clone());
622 result.push(def);
623 }
624 }
625
626 Ok(result)
627 }
628}
629
630#[derive(Serialize)]
635struct WritableRawDef<'a> {
636 name: &'a str,
637 description: &'a str,
638 #[serde(skip_serializing_if = "Option::is_none")]
639 model: Option<&'a ModelSpec>,
640 #[serde(skip_serializing_if = "WritableToolPolicy::is_inherit_all")]
641 tools: WritableToolPolicy<'a>,
642 #[serde(skip_serializing_if = "WritablePermissions::is_default")]
643 permissions: WritablePermissions<'a>,
644 #[serde(skip_serializing_if = "SkillFilter::is_empty")]
645 skills: &'a SkillFilter,
646 #[serde(skip_serializing_if = "SubagentHooks::is_empty")]
647 hooks: &'a SubagentHooks,
648 #[serde(skip_serializing_if = "Option::is_none")]
649 memory: Option<MemoryScope>,
650}
651
652#[derive(Serialize)]
653struct WritableToolPolicy<'a> {
654 #[serde(skip_serializing_if = "Option::is_none")]
655 allow: Option<&'a Vec<String>>,
656 #[serde(skip_serializing_if = "Option::is_none")]
657 deny: Option<&'a Vec<String>>,
658 #[serde(skip_serializing_if = "Vec::is_empty")]
659 except: &'a Vec<String>,
660}
661
662impl<'a> WritableToolPolicy<'a> {
663 fn from_def(policy: &'a ToolPolicy, except: &'a Vec<String>) -> Self {
664 match policy {
665 ToolPolicy::AllowList(v) => Self {
666 allow: Some(v),
667 deny: None,
668 except,
669 },
670 ToolPolicy::DenyList(v) => Self {
671 allow: None,
672 deny: Some(v),
673 except,
674 },
675 ToolPolicy::InheritAll => Self {
676 allow: None,
677 deny: None,
678 except,
679 },
680 }
681 }
682
683 fn is_inherit_all(&self) -> bool {
684 self.allow.is_none() && self.deny.is_none() && self.except.is_empty()
685 }
686}
687
688#[derive(Serialize)]
689struct WritablePermissions<'a> {
690 #[serde(skip_serializing_if = "Vec::is_empty")]
691 secrets: &'a Vec<String>,
692 max_turns: u32,
693 background: bool,
694 timeout_secs: u64,
695 ttl_secs: u64,
696 permission_mode: PermissionMode,
697}
698
699impl<'a> WritablePermissions<'a> {
700 fn from_def(p: &'a SubAgentPermissions) -> Self {
701 Self {
702 secrets: &p.secrets,
703 max_turns: p.max_turns,
704 background: p.background,
705 timeout_secs: p.timeout_secs,
706 ttl_secs: p.ttl_secs,
707 permission_mode: p.permission_mode,
708 }
709 }
710
711 fn is_default(&self) -> bool {
712 self.secrets.is_empty()
713 && self.max_turns == default_max_turns()
714 && !self.background
715 && self.timeout_secs == default_timeout()
716 && self.ttl_secs == default_ttl()
717 && self.permission_mode == PermissionMode::Default
718 }
719}
720
721impl SubAgentDef {
722 #[must_use]
731 pub fn serialize_to_markdown(&self) -> String {
732 let tools = WritableToolPolicy::from_def(&self.tools, &self.disallowed_tools);
733 let permissions = WritablePermissions::from_def(&self.permissions);
734
735 let writable = WritableRawDef {
736 name: &self.name,
737 description: &self.description,
738 model: self.model.as_ref(),
739 tools,
740 permissions,
741 skills: &self.skills,
742 hooks: &self.hooks,
743 memory: self.memory,
744 };
745
746 let yaml = serde_norway::to_string(&writable).expect("serialization cannot fail");
747 if self.system_prompt.is_empty() {
748 format!("---\n{yaml}---\n")
749 } else {
750 format!("---\n{yaml}---\n\n{}\n", self.system_prompt)
751 }
752 }
753
754 pub fn save_atomic(&self, dir: &Path) -> Result<PathBuf, SubAgentError> {
764 if !AGENT_NAME_RE.is_match(&self.name) {
765 return Err(SubAgentError::Invalid(format!(
766 "name '{}' is invalid: must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{{0,63}}$",
767 self.name
768 )));
769 }
770 std::fs::create_dir_all(dir).map_err(|e| SubAgentError::Io {
771 path: dir.display().to_string(),
772 reason: format!("cannot create directory: {e}"),
773 })?;
774
775 let content = self.serialize_to_markdown();
776 let target = dir.join(format!("{}.md", self.name));
777
778 let mut tmp = NamedTempFile::new_in(dir).map_err(|e| SubAgentError::Io {
779 path: dir.display().to_string(),
780 reason: format!("cannot create temp file: {e}"),
781 })?;
782
783 std::io::Write::write_all(&mut tmp, content.as_bytes()).map_err(|e| SubAgentError::Io {
784 path: dir.display().to_string(),
785 reason: format!("cannot write temp file: {e}"),
786 })?;
787
788 tmp.persist(&target).map_err(|e| SubAgentError::Io {
789 path: target.display().to_string(),
790 reason: format!("cannot rename temp file: {e}"),
791 })?;
792
793 Ok(target)
794 }
795
796 pub fn delete_file(path: &Path) -> Result<(), SubAgentError> {
802 std::fs::remove_file(path).map_err(|e| SubAgentError::Io {
803 path: path.display().to_string(),
804 reason: e.to_string(),
805 })
806 }
807
808 #[must_use]
812 pub fn default_template(name: impl Into<String>, description: impl Into<String>) -> Self {
813 Self {
814 name: name.into(),
815 description: description.into(),
816 model: None,
817 tools: ToolPolicy::InheritAll,
818 disallowed_tools: Vec::new(),
819 permissions: SubAgentPermissions::default(),
820 skills: SkillFilter::default(),
821 hooks: SubagentHooks::default(),
822 memory: None,
823 system_prompt: String::new(),
824 source: None,
825 file_path: None,
826 }
827 }
828}
829
830#[cfg(test)]
833mod tests {
834 #![allow(clippy::cloned_ref_to_slice_refs)]
835
836 use indoc::indoc;
837
838 use super::*;
839
840 const FULL_DEF_YAML: &str = indoc! {"
843 ---
844 name: code-reviewer
845 description: Reviews code changes for correctness and style
846 model: claude-sonnet-4-20250514
847 tools:
848 allow:
849 - shell
850 - web_scrape
851 permissions:
852 secrets:
853 - github-token
854 max_turns: 10
855 background: false
856 timeout_secs: 300
857 ttl_secs: 120
858 skills:
859 include:
860 - \"git-*\"
861 - \"rust-*\"
862 exclude:
863 - \"deploy-*\"
864 ---
865
866 You are a code reviewer. Report findings with severity.
867 "};
868
869 const MINIMAL_DEF_YAML: &str = indoc! {"
870 ---
871 name: bot
872 description: A bot
873 ---
874
875 Do things.
876 "};
877
878 const FULL_DEF_TOML: &str = indoc! {"
881 +++
882 name = \"code-reviewer\"
883 description = \"Reviews code changes for correctness and style\"
884 model = \"claude-sonnet-4-20250514\"
885
886 [tools]
887 allow = [\"shell\", \"web_scrape\"]
888
889 [permissions]
890 secrets = [\"github-token\"]
891 max_turns = 10
892 background = false
893 timeout_secs = 300
894 ttl_secs = 120
895
896 [skills]
897 include = [\"git-*\", \"rust-*\"]
898 exclude = [\"deploy-*\"]
899 +++
900
901 You are a code reviewer. Report findings with severity.
902 "};
903
904 const MINIMAL_DEF_TOML: &str = indoc! {"
905 +++
906 name = \"bot\"
907 description = \"A bot\"
908 +++
909
910 Do things.
911 "};
912
913 #[test]
916 fn parse_yaml_full_definition() {
917 let def = SubAgentDef::parse(FULL_DEF_YAML).unwrap();
918 assert_eq!(def.name, "code-reviewer");
919 assert_eq!(
920 def.description,
921 "Reviews code changes for correctness and style"
922 );
923 assert_eq!(
924 def.model,
925 Some(ModelSpec::Named("claude-sonnet-4-20250514".to_owned()))
926 );
927 assert!(matches!(def.tools, ToolPolicy::AllowList(ref v) if v == &["shell", "web_scrape"]));
928 assert_eq!(def.permissions.max_turns, 10);
929 assert_eq!(def.permissions.secrets, ["github-token"]);
930 assert_eq!(def.skills.include, ["git-*", "rust-*"]);
931 assert_eq!(def.skills.exclude, ["deploy-*"]);
932 assert!(def.system_prompt.contains("code reviewer"));
933 }
934
935 #[test]
936 fn parse_yaml_minimal_definition() {
937 let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
938 assert_eq!(def.name, "bot");
939 assert_eq!(def.description, "A bot");
940 assert!(def.model.is_none());
941 assert!(matches!(def.tools, ToolPolicy::InheritAll));
942 assert_eq!(def.permissions.max_turns, 20);
943 assert_eq!(def.permissions.timeout_secs, 600);
944 assert_eq!(def.permissions.ttl_secs, 300);
945 assert!(!def.permissions.background);
946 assert_eq!(def.system_prompt, "Do things.");
947 }
948
949 #[test]
950 fn parse_yaml_with_dashes_in_body() {
951 let content = "---\nname: agent\ndescription: desc\n---\n\nSome text\n---\nMore text\n";
953 let def = SubAgentDef::parse(content).unwrap();
954 assert_eq!(def.name, "agent");
955 assert!(def.system_prompt.contains("Some text"));
956 assert!(def.system_prompt.contains("More text"));
957 }
958
959 #[test]
960 fn parse_yaml_tool_deny_list() {
961 let content = "---\nname: a\ndescription: b\ntools:\n deny:\n - shell\n---\n\nbody\n";
962 let def = SubAgentDef::parse(content).unwrap();
963 assert!(matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["shell"]));
964 }
965
966 #[test]
967 fn parse_yaml_tool_inherit_all() {
968 let content = "---\nname: a\ndescription: b\ntools: {}\n---\n\nbody\n";
970 let def = SubAgentDef::parse(content).unwrap();
971 assert!(matches!(def.tools, ToolPolicy::InheritAll));
972 }
973
974 #[test]
975 fn parse_yaml_tool_both_specified_is_error() {
976 let content = "---\nname: a\ndescription: b\ntools:\n allow:\n - x\n deny:\n - y\n---\n\nbody\n";
977 let err = SubAgentDef::parse(content).unwrap_err();
978 assert!(matches!(err, SubAgentError::Invalid(_)));
979 }
980
981 #[test]
982 fn parse_yaml_missing_closing_delimiter() {
983 let err = SubAgentDef::parse("---\nname: a\ndescription: b\n").unwrap_err();
984 assert!(matches!(err, SubAgentError::Parse { .. }));
985 }
986
987 #[test]
988 fn parse_yaml_crlf_line_endings() {
989 let content = "---\r\nname: bot\r\ndescription: A bot\r\n---\r\n\r\nDo things.\r\n";
990 let def = SubAgentDef::parse(content).unwrap();
991 assert_eq!(def.name, "bot");
992 assert_eq!(def.description, "A bot");
993 assert!(!def.system_prompt.is_empty());
994 }
995
996 #[test]
997 fn parse_yaml_missing_required_field_name() {
998 let content = "---\ndescription: b\n---\n\nbody\n";
999 let err = SubAgentDef::parse(content).unwrap_err();
1000 assert!(matches!(err, SubAgentError::Parse { .. }));
1001 }
1002
1003 #[test]
1004 fn parse_yaml_missing_required_field_description() {
1005 let content = "---\nname: a\n---\n\nbody\n";
1006 let err = SubAgentDef::parse(content).unwrap_err();
1007 assert!(matches!(err, SubAgentError::Parse { .. }));
1008 }
1009
1010 #[test]
1011 fn parse_yaml_empty_name_is_invalid() {
1012 let content = "---\nname: \"\"\ndescription: b\n---\n\nbody\n";
1013 let err = SubAgentDef::parse(content).unwrap_err();
1014 assert!(matches!(err, SubAgentError::Invalid(_)));
1015 }
1016
1017 #[test]
1018 fn parse_yaml_whitespace_only_description_is_invalid() {
1019 let content = "---\nname: a\ndescription: \" \"\n---\n\nbody\n";
1020 let err = SubAgentDef::parse(content).unwrap_err();
1021 assert!(matches!(err, SubAgentError::Invalid(_)));
1022 }
1023
1024 #[test]
1025 fn parse_yaml_crlf_with_numeric_fields() {
1026 let content = "---\r\nname: bot\r\ndescription: A bot\r\npermissions:\r\n max_turns: 5\r\n timeout_secs: 120\r\n---\r\n\r\nDo things.\r\n";
1027 let def = SubAgentDef::parse(content).unwrap();
1028 assert_eq!(def.permissions.max_turns, 5);
1029 assert_eq!(def.permissions.timeout_secs, 120);
1030 }
1031
1032 #[test]
1033 fn parse_yaml_no_trailing_newline() {
1034 let content = "---\nname: a\ndescription: b\n---";
1035 let def = SubAgentDef::parse(content).unwrap();
1036 assert_eq!(def.system_prompt, "");
1037 }
1038
1039 #[test]
1042 fn parse_full_definition() {
1043 let def = SubAgentDef::parse(FULL_DEF_TOML).unwrap();
1044 assert_eq!(def.name, "code-reviewer");
1045 assert_eq!(
1046 def.description,
1047 "Reviews code changes for correctness and style"
1048 );
1049 assert_eq!(
1050 def.model,
1051 Some(ModelSpec::Named("claude-sonnet-4-20250514".to_owned()))
1052 );
1053 assert!(matches!(def.tools, ToolPolicy::AllowList(ref v) if v == &["shell", "web_scrape"]));
1054 assert_eq!(def.permissions.max_turns, 10);
1055 assert_eq!(def.permissions.secrets, ["github-token"]);
1056 assert_eq!(def.skills.include, ["git-*", "rust-*"]);
1057 assert_eq!(def.skills.exclude, ["deploy-*"]);
1058 assert!(def.system_prompt.contains("code reviewer"));
1059 }
1060
1061 #[test]
1062 fn parse_minimal_definition() {
1063 let def = SubAgentDef::parse(MINIMAL_DEF_TOML).unwrap();
1064 assert_eq!(def.name, "bot");
1065 assert_eq!(def.description, "A bot");
1066 assert!(def.model.is_none());
1067 assert!(matches!(def.tools, ToolPolicy::InheritAll));
1068 assert_eq!(def.permissions.max_turns, 20);
1069 assert_eq!(def.permissions.timeout_secs, 600);
1070 assert_eq!(def.permissions.ttl_secs, 300);
1071 assert!(!def.permissions.background);
1072 assert_eq!(def.system_prompt, "Do things.");
1073 }
1074
1075 #[test]
1076 fn tool_policy_deny_list() {
1077 let content =
1078 "+++\nname = \"a\"\ndescription = \"b\"\n[tools]\ndeny = [\"shell\"]\n+++\n\nbody\n";
1079 let def = SubAgentDef::parse(content).unwrap();
1080 assert!(matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["shell"]));
1081 }
1082
1083 #[test]
1084 fn tool_policy_inherit_all() {
1085 let def = SubAgentDef::parse(MINIMAL_DEF_TOML).unwrap();
1086 assert!(matches!(def.tools, ToolPolicy::InheritAll));
1087 }
1088
1089 #[test]
1090 fn tool_policy_both_specified_is_error() {
1091 let content = "+++\nname = \"a\"\ndescription = \"b\"\n[tools]\nallow = [\"x\"]\ndeny = [\"y\"]\n+++\n\nbody\n";
1092 let err = SubAgentDef::parse(content).unwrap_err();
1093 assert!(matches!(err, SubAgentError::Invalid(_)));
1094 }
1095
1096 #[test]
1097 fn missing_opening_delimiter() {
1098 let err = SubAgentDef::parse("name = \"a\"\n+++\nbody\n").unwrap_err();
1099 assert!(matches!(err, SubAgentError::Parse { .. }));
1100 }
1101
1102 #[test]
1103 fn missing_closing_delimiter() {
1104 let err = SubAgentDef::parse("+++\nname = \"a\"\ndescription = \"b\"\n").unwrap_err();
1105 assert!(matches!(err, SubAgentError::Parse { .. }));
1106 }
1107
1108 #[test]
1109 fn missing_required_field_name() {
1110 let content = "+++\ndescription = \"b\"\n+++\n\nbody\n";
1111 let err = SubAgentDef::parse(content).unwrap_err();
1112 assert!(matches!(err, SubAgentError::Parse { .. }));
1113 }
1114
1115 #[test]
1116 fn missing_required_field_description() {
1117 let content = "+++\nname = \"a\"\n+++\n\nbody\n";
1118 let err = SubAgentDef::parse(content).unwrap_err();
1119 assert!(matches!(err, SubAgentError::Parse { .. }));
1120 }
1121
1122 #[test]
1123 fn empty_name_is_invalid() {
1124 let content = "+++\nname = \"\"\ndescription = \"b\"\n+++\n\nbody\n";
1125 let err = SubAgentDef::parse(content).unwrap_err();
1126 assert!(matches!(err, SubAgentError::Invalid(_)));
1127 }
1128
1129 #[test]
1130 fn load_all_deduplication_by_name() {
1131 use std::io::Write as _;
1132 let dir1 = tempfile::tempdir().unwrap();
1133 let dir2 = tempfile::tempdir().unwrap();
1134
1135 let content1 = "---\nname: bot\ndescription: from dir1\n---\n\ndir1 prompt\n";
1136 let content2 = "---\nname: bot\ndescription: from dir2\n---\n\ndir2 prompt\n";
1137
1138 let mut f1 = std::fs::File::create(dir1.path().join("bot.md")).unwrap();
1139 f1.write_all(content1.as_bytes()).unwrap();
1140
1141 let mut f2 = std::fs::File::create(dir2.path().join("bot.md")).unwrap();
1142 f2.write_all(content2.as_bytes()).unwrap();
1143
1144 let search_dirs = vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()];
1145 let defs = SubAgentDef::load_all(&search_dirs).unwrap();
1146
1147 assert_eq!(defs.len(), 1);
1148 assert_eq!(defs[0].description, "from dir1");
1149 }
1150
1151 #[test]
1152 fn default_permissions_values() {
1153 let p = SubAgentPermissions::default();
1154 assert_eq!(p.max_turns, 20);
1155 assert_eq!(p.timeout_secs, 600);
1156 assert_eq!(p.ttl_secs, 300);
1157 assert!(!p.background);
1158 assert!(p.secrets.is_empty());
1159 }
1160
1161 #[test]
1162 fn whitespace_only_description_is_invalid() {
1163 let content = "+++\nname = \"a\"\ndescription = \" \"\n+++\n\nbody\n";
1164 let err = SubAgentDef::parse(content).unwrap_err();
1165 assert!(matches!(err, SubAgentError::Invalid(_)));
1166 }
1167
1168 #[test]
1169 fn load_nonexistent_file_returns_parse_error() {
1170 let err =
1171 SubAgentDef::load(std::path::Path::new("/tmp/does-not-exist-zeph.md")).unwrap_err();
1172 assert!(matches!(err, SubAgentError::Parse { .. }));
1173 }
1174
1175 #[test]
1176 fn parse_crlf_line_endings() {
1177 let content =
1178 "+++\r\nname = \"bot\"\r\ndescription = \"A bot\"\r\n+++\r\n\r\nDo things.\r\n";
1179 let def = SubAgentDef::parse(content).unwrap();
1180 assert_eq!(def.name, "bot");
1181 assert_eq!(def.description, "A bot");
1182 assert!(!def.system_prompt.is_empty());
1183 }
1184
1185 #[test]
1186 fn parse_crlf_closing_delimiter() {
1187 let content = "+++\r\nname = \"bot\"\r\ndescription = \"A bot\"\r\n+++\r\nPrompt here.\r\n";
1188 let def = SubAgentDef::parse(content).unwrap();
1189 assert!(def.system_prompt.contains("Prompt here"));
1190 }
1191
1192 #[test]
1193 fn load_all_warn_and_skip_on_parse_error_for_non_cli_source() {
1194 use std::io::Write as _;
1195 let dir = tempfile::tempdir().unwrap();
1196
1197 let valid = "---\nname: good\ndescription: ok\n---\n\nbody\n";
1198 let invalid = "this is not valid frontmatter";
1199
1200 let mut f1 = std::fs::File::create(dir.path().join("a_good.md")).unwrap();
1201 f1.write_all(valid.as_bytes()).unwrap();
1202
1203 let mut f2 = std::fs::File::create(dir.path().join("b_bad.md")).unwrap();
1204 f2.write_all(invalid.as_bytes()).unwrap();
1205
1206 let defs = SubAgentDef::load_all(&[dir.path().to_path_buf()]).unwrap();
1208 assert_eq!(defs.len(), 1);
1209 assert_eq!(defs[0].name, "good");
1210 }
1211
1212 #[test]
1213 fn load_all_with_sources_hard_error_for_cli_file() {
1214 use std::io::Write as _;
1215 let dir = tempfile::tempdir().unwrap();
1216
1217 let invalid = "this is not valid frontmatter";
1218 let bad_path = dir.path().join("bad.md");
1219 let mut f = std::fs::File::create(&bad_path).unwrap();
1220 f.write_all(invalid.as_bytes()).unwrap();
1221
1222 let err = SubAgentDef::load_all_with_sources(
1224 std::slice::from_ref(&bad_path),
1225 std::slice::from_ref(&bad_path),
1226 None,
1227 &[],
1228 )
1229 .unwrap_err();
1230 assert!(matches!(err, SubAgentError::Parse { .. }));
1231 }
1232
1233 #[test]
1234 fn load_all_with_sources_max_entries_per_dir_cap() {
1235 let dir = tempfile::tempdir().unwrap();
1237 let total = MAX_ENTRIES_PER_DIR + 10;
1238 for i in 0..total {
1239 let content =
1240 format!("---\nname: agent-{i:04}\ndescription: Agent {i}\n---\n\nBody {i}\n");
1241 std::fs::write(dir.path().join(format!("agent-{i:04}.md")), &content).unwrap();
1242 }
1243 let defs = SubAgentDef::load_all(&[dir.path().to_path_buf()]).unwrap();
1244 assert_eq!(
1245 defs.len(),
1246 MAX_ENTRIES_PER_DIR,
1247 "must cap at MAX_ENTRIES_PER_DIR=100"
1248 );
1249 }
1250
1251 #[test]
1252 fn load_with_boundary_rejects_symlink_escape() {
1253 let dir_a = tempfile::tempdir().unwrap();
1256 let dir_b = tempfile::tempdir().unwrap();
1257
1258 let real_file = dir_b.path().join("agent.md");
1259 std::fs::write(
1260 &real_file,
1261 "---\nname: escape\ndescription: Escaped\n---\n\nBody\n",
1262 )
1263 .unwrap();
1264
1265 #[cfg(not(unix))]
1266 {
1267 let _ = (dir_a, dir_b, real_file);
1269 return;
1270 }
1271
1272 #[cfg(unix)]
1273 {
1274 let link_path = dir_a.path().join("agent.md");
1275 std::os::unix::fs::symlink(&real_file, &link_path).unwrap();
1276 let boundary = std::fs::canonicalize(dir_a.path()).unwrap();
1277 let err =
1278 SubAgentDef::load_with_boundary(&link_path, Some(&boundary), None).unwrap_err();
1279 assert!(
1280 matches!(&err, SubAgentError::Parse { reason, .. } if reason.contains("escapes allowed directory boundary")),
1281 "expected boundary violation error, got: {err}"
1282 );
1283 }
1284 }
1285
1286 #[test]
1287 fn load_all_with_sources_source_field_has_correct_scope_label() {
1288 use std::io::Write as _;
1289 let user_dir = tempfile::tempdir().unwrap();
1291 let user_dir_path = user_dir.path().to_path_buf();
1292 let content = "---\nname: my-agent\ndescription: test\n---\n\nBody\n";
1293 let mut f = std::fs::File::create(user_dir_path.join("my-agent.md")).unwrap();
1294 f.write_all(content.as_bytes()).unwrap();
1295
1296 let paths = vec![user_dir_path.clone()];
1298 let defs =
1299 SubAgentDef::load_all_with_sources(&paths, &[], Some(&user_dir_path), &[]).unwrap();
1300
1301 assert_eq!(defs.len(), 1);
1302 let source = defs[0].source.as_deref().unwrap_or("");
1303 assert!(
1304 source.starts_with("user/"),
1305 "expected source to start with 'user/', got: {source}"
1306 );
1307 }
1308
1309 #[test]
1310 fn load_all_with_sources_priority_first_name_wins() {
1311 use std::io::Write as _;
1312 let dir1 = tempfile::tempdir().unwrap();
1313 let dir2 = tempfile::tempdir().unwrap();
1314
1315 let content1 = "---\nname: bot\ndescription: from dir1\n---\n\ndir1 prompt\n";
1317 let content2 = "---\nname: bot\ndescription: from dir2\n---\n\ndir2 prompt\n";
1318
1319 let mut f1 = std::fs::File::create(dir1.path().join("bot.md")).unwrap();
1320 f1.write_all(content1.as_bytes()).unwrap();
1321 let mut f2 = std::fs::File::create(dir2.path().join("bot.md")).unwrap();
1322 f2.write_all(content2.as_bytes()).unwrap();
1323
1324 let paths = vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()];
1326 let defs = SubAgentDef::load_all_with_sources(&paths, &[], None, &[]).unwrap();
1327
1328 assert_eq!(defs.len(), 1, "name collision: only first wins");
1329 assert_eq!(defs[0].description, "from dir1");
1330 }
1331
1332 #[test]
1333 fn load_all_with_sources_user_agents_dir_none_skips_gracefully() {
1334 let dir = tempfile::tempdir().unwrap();
1337 let content = "---\nname: ok\ndescription: fine\n---\n\nBody\n";
1338 std::fs::write(dir.path().join("ok.md"), content).unwrap();
1339
1340 let paths = vec![dir.path().to_path_buf()];
1342 let defs = SubAgentDef::load_all_with_sources(&paths, &[], None, &[]).unwrap();
1343 assert_eq!(defs.len(), 1);
1344 assert_eq!(defs[0].name, "ok");
1345 }
1346
1347 #[test]
1350 fn parse_yaml_permission_mode_default_when_omitted() {
1351 let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1352 assert_eq!(def.permissions.permission_mode, PermissionMode::Default);
1353 }
1354
1355 #[test]
1356 fn parse_yaml_permission_mode_dont_ask() {
1357 let content = "---\nname: a\ndescription: b\npermissions:\n permission_mode: dont_ask\n---\n\nbody\n";
1358 let def = SubAgentDef::parse(content).unwrap();
1359 assert_eq!(def.permissions.permission_mode, PermissionMode::DontAsk);
1360 }
1361
1362 #[test]
1363 fn parse_yaml_permission_mode_accept_edits() {
1364 let content = "---\nname: a\ndescription: b\npermissions:\n permission_mode: accept_edits\n---\n\nbody\n";
1365 let def = SubAgentDef::parse(content).unwrap();
1366 assert_eq!(def.permissions.permission_mode, PermissionMode::AcceptEdits);
1367 }
1368
1369 #[test]
1370 fn parse_yaml_permission_mode_bypass_permissions() {
1371 let content = "---\nname: a\ndescription: b\npermissions:\n permission_mode: bypass_permissions\n---\n\nbody\n";
1372 let def = SubAgentDef::parse(content).unwrap();
1373 assert_eq!(
1374 def.permissions.permission_mode,
1375 PermissionMode::BypassPermissions
1376 );
1377 }
1378
1379 #[test]
1380 fn parse_yaml_permission_mode_plan() {
1381 let content =
1382 "---\nname: a\ndescription: b\npermissions:\n permission_mode: plan\n---\n\nbody\n";
1383 let def = SubAgentDef::parse(content).unwrap();
1384 assert_eq!(def.permissions.permission_mode, PermissionMode::Plan);
1385 }
1386
1387 #[test]
1388 fn parse_yaml_disallowed_tools_from_except() {
1389 let content = "---\nname: a\ndescription: b\ntools:\n allow:\n - shell\n - web\n except:\n - shell\n---\n\nbody\n";
1390 let def = SubAgentDef::parse(content).unwrap();
1391 assert!(
1392 matches!(def.tools, ToolPolicy::AllowList(ref v) if v.contains(&"shell".to_owned()))
1393 );
1394 assert_eq!(def.disallowed_tools, ["shell"]);
1395 }
1396
1397 #[test]
1398 fn parse_yaml_disallowed_tools_empty_when_no_except() {
1399 let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1400 assert!(def.disallowed_tools.is_empty());
1401 }
1402
1403 #[test]
1404 fn parse_yaml_all_new_fields_together() {
1405 let content = indoc! {"
1406 ---
1407 name: planner
1408 description: Plans things
1409 tools:
1410 allow:
1411 - shell
1412 - web
1413 except:
1414 - dangerous
1415 permissions:
1416 max_turns: 5
1417 background: true
1418 permission_mode: plan
1419 ---
1420
1421 You are a planner.
1422 "};
1423 let def = SubAgentDef::parse(content).unwrap();
1424 assert_eq!(def.permissions.permission_mode, PermissionMode::Plan);
1425 assert!(def.permissions.background);
1426 assert_eq!(def.permissions.max_turns, 5);
1427 assert_eq!(def.disallowed_tools, ["dangerous"]);
1428 }
1429
1430 #[test]
1431 fn default_permissions_includes_permission_mode_default() {
1432 let p = SubAgentPermissions::default();
1433 assert_eq!(p.permission_mode, PermissionMode::Default);
1434 }
1435
1436 #[test]
1439 fn parse_yaml_unknown_permission_mode_variant_is_error() {
1440 let content = "---\nname: a\ndescription: b\npermissions:\n permission_mode: banana_mode\n---\n\nbody\n";
1442 let err = SubAgentDef::parse(content).unwrap_err();
1443 assert!(matches!(err, SubAgentError::Parse { .. }));
1444 }
1445
1446 #[test]
1447 fn parse_yaml_permission_mode_case_sensitive_camel_is_error() {
1448 let content =
1450 "---\nname: a\ndescription: b\npermissions:\n permission_mode: DontAsk\n---\n\nbody\n";
1451 let err = SubAgentDef::parse(content).unwrap_err();
1452 assert!(matches!(err, SubAgentError::Parse { .. }));
1453 }
1454
1455 #[test]
1456 fn parse_yaml_explicit_empty_except_gives_empty_disallowed_tools() {
1457 let content = "---\nname: a\ndescription: b\ntools:\n allow:\n - shell\n except: []\n---\n\nbody\n";
1458 let def = SubAgentDef::parse(content).unwrap();
1459 assert!(def.disallowed_tools.is_empty());
1460 }
1461
1462 #[test]
1463 fn parse_yaml_disallowed_tools_with_deny_list_deny_wins() {
1464 let content = "---\nname: a\ndescription: b\ntools:\n deny:\n - dangerous\n except:\n - web\n---\n\nbody\n";
1467 let def = SubAgentDef::parse(content).unwrap();
1468 assert!(matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["dangerous"]));
1470 assert!(def.disallowed_tools.contains(&"web".to_owned()));
1472 }
1473
1474 #[test]
1475 fn parse_toml_background_true_frontmatter() {
1476 let content = "+++\nname = \"bg-agent\"\ndescription = \"Runs in background\"\n[permissions]\nbackground = true\n+++\n\nSystem prompt.\n";
1478 let def = SubAgentDef::parse(content).unwrap();
1479 assert!(def.permissions.background);
1480 assert_eq!(def.name, "bg-agent");
1481 }
1482
1483 #[test]
1484 fn parse_yaml_unknown_top_level_field_is_error() {
1485 let content = "---\nname: a\ndescription: b\npermisions:\n max_turns: 5\n---\n\nbody\n";
1487 let err = SubAgentDef::parse(content).unwrap_err();
1488 assert!(matches!(err, SubAgentError::Parse { .. }));
1489 }
1490
1491 #[test]
1494 fn parse_yaml_memory_scope_project() {
1495 let content =
1496 "---\nname: reviewer\ndescription: A reviewer\nmemory: project\n---\n\nBody.\n";
1497 let def = SubAgentDef::parse(content).unwrap();
1498 assert_eq!(def.memory, Some(MemoryScope::Project));
1499 }
1500
1501 #[test]
1502 fn parse_yaml_memory_scope_user() {
1503 let content = "---\nname: reviewer\ndescription: A reviewer\nmemory: user\n---\n\nBody.\n";
1504 let def = SubAgentDef::parse(content).unwrap();
1505 assert_eq!(def.memory, Some(MemoryScope::User));
1506 }
1507
1508 #[test]
1509 fn parse_yaml_memory_scope_local() {
1510 let content = "---\nname: reviewer\ndescription: A reviewer\nmemory: local\n---\n\nBody.\n";
1511 let def = SubAgentDef::parse(content).unwrap();
1512 assert_eq!(def.memory, Some(MemoryScope::Local));
1513 }
1514
1515 #[test]
1516 fn parse_yaml_memory_absent_gives_none() {
1517 let content = "---\nname: reviewer\ndescription: A reviewer\n---\n\nBody.\n";
1518 let def = SubAgentDef::parse(content).unwrap();
1519 assert!(def.memory.is_none());
1520 }
1521
1522 #[test]
1523 fn parse_yaml_memory_invalid_value_is_error() {
1524 let content =
1525 "---\nname: reviewer\ndescription: A reviewer\nmemory: global\n---\n\nBody.\n";
1526 let err = SubAgentDef::parse(content).unwrap_err();
1527 assert!(matches!(err, SubAgentError::Parse { .. }));
1528 }
1529
1530 #[test]
1531 fn memory_scope_serde_roundtrip() {
1532 for scope in [MemoryScope::User, MemoryScope::Project, MemoryScope::Local] {
1533 let json = serde_json::to_string(&scope).unwrap();
1534 let parsed: MemoryScope = serde_json::from_str(&json).unwrap();
1535 assert_eq!(parsed, scope);
1536 }
1537 }
1538
1539 #[test]
1542 fn parse_yaml_name_with_unicode_is_invalid() {
1543 let content = "---\nname: аgent\ndescription: b\n---\n\nbody\n";
1545 let err = SubAgentDef::parse(content).unwrap_err();
1546 assert!(matches!(err, SubAgentError::Invalid(_)));
1547 }
1548
1549 #[test]
1550 fn parse_yaml_name_with_space_is_invalid() {
1551 let content = "---\nname: my agent\ndescription: b\n---\n\nbody\n";
1552 let err = SubAgentDef::parse(content).unwrap_err();
1553 assert!(matches!(err, SubAgentError::Invalid(_)));
1554 }
1555
1556 #[test]
1557 fn parse_yaml_name_with_dot_is_invalid() {
1558 let content = "---\nname: my.agent\ndescription: b\n---\n\nbody\n";
1559 let err = SubAgentDef::parse(content).unwrap_err();
1560 assert!(matches!(err, SubAgentError::Invalid(_)));
1561 }
1562
1563 #[test]
1564 fn parse_yaml_name_single_char_is_valid() {
1565 let content = "---\nname: a\ndescription: b\n---\n\nbody\n";
1566 let def = SubAgentDef::parse(content).unwrap();
1567 assert_eq!(def.name, "a");
1568 }
1569
1570 #[test]
1571 fn parse_yaml_name_with_underscore_and_hyphen_is_valid() {
1572 let content = "---\nname: my_agent-v2\ndescription: b\n---\n\nbody\n";
1573 let def = SubAgentDef::parse(content).unwrap();
1574 assert_eq!(def.name, "my_agent-v2");
1575 }
1576
1577 #[test]
1580 fn default_template_valid() {
1581 let def = SubAgentDef::default_template("tester", "Runs tests");
1582 assert_eq!(def.name, "tester");
1583 assert_eq!(def.description, "Runs tests");
1584 assert!(def.model.is_none());
1585 assert!(matches!(def.tools, ToolPolicy::InheritAll));
1586 assert!(def.system_prompt.is_empty());
1587 }
1588
1589 #[test]
1590 fn default_template_roundtrip() {
1591 let def = SubAgentDef::default_template("tester", "Runs tests");
1592 let markdown = def.serialize_to_markdown();
1593 let parsed = SubAgentDef::parse(&markdown).unwrap();
1594 assert_eq!(parsed.name, "tester");
1595 assert_eq!(parsed.description, "Runs tests");
1596 }
1597
1598 #[test]
1599 fn serialize_minimal() {
1600 let def = SubAgentDef::default_template("bot", "A bot");
1601 let md = def.serialize_to_markdown();
1602 assert!(md.starts_with("---\n"));
1603 assert!(md.contains("name: bot"));
1604 assert!(md.contains("description: A bot"));
1605 }
1606
1607 #[test]
1608 fn serialize_roundtrip() {
1609 let content = indoc! {"
1610 ---
1611 name: code-reviewer
1612 description: Reviews code changes for correctness and style
1613 model: claude-sonnet-4-20250514
1614 tools:
1615 allow:
1616 - shell
1617 - web_scrape
1618 permissions:
1619 max_turns: 10
1620 background: false
1621 timeout_secs: 300
1622 ttl_secs: 120
1623 skills:
1624 include:
1625 - \"git-*\"
1626 - \"rust-*\"
1627 exclude:
1628 - \"deploy-*\"
1629 ---
1630
1631 You are a code reviewer. Report findings with severity.
1632 "};
1633 let def = SubAgentDef::parse(content).unwrap();
1634 let serialized = def.serialize_to_markdown();
1635 let reparsed = SubAgentDef::parse(&serialized).unwrap();
1636 assert_eq!(reparsed.name, def.name);
1637 assert_eq!(reparsed.description, def.description);
1638 assert_eq!(reparsed.model, def.model);
1639 assert_eq!(reparsed.permissions.max_turns, def.permissions.max_turns);
1640 assert_eq!(
1641 reparsed.permissions.timeout_secs,
1642 def.permissions.timeout_secs
1643 );
1644 assert_eq!(reparsed.permissions.ttl_secs, def.permissions.ttl_secs);
1645 assert_eq!(reparsed.permissions.background, def.permissions.background);
1646 assert_eq!(
1647 reparsed.permissions.permission_mode,
1648 def.permissions.permission_mode
1649 );
1650 assert_eq!(reparsed.skills.include, def.skills.include);
1651 assert_eq!(reparsed.skills.exclude, def.skills.exclude);
1652 assert_eq!(reparsed.system_prompt, def.system_prompt);
1653 assert!(
1654 matches!(&reparsed.tools, ToolPolicy::AllowList(v) if v == &["shell", "web_scrape"])
1655 );
1656 }
1657
1658 #[test]
1659 fn serialize_roundtrip_tools_except() {
1660 let content = indoc! {"
1661 ---
1662 name: auditor
1663 description: Security auditor
1664 tools:
1665 allow:
1666 - shell
1667 except:
1668 - shell_sudo
1669 - shell_rm
1670 ---
1671
1672 Audit mode.
1673 "};
1674 let def = SubAgentDef::parse(content).unwrap();
1675 let serialized = def.serialize_to_markdown();
1676 let reparsed = SubAgentDef::parse(&serialized).unwrap();
1677 assert_eq!(reparsed.disallowed_tools, def.disallowed_tools);
1678 assert_eq!(reparsed.disallowed_tools, ["shell_sudo", "shell_rm"]);
1679 assert!(matches!(&reparsed.tools, ToolPolicy::AllowList(v) if v == &["shell"]));
1680 }
1681
1682 #[test]
1683 fn serialize_all_fields() {
1684 let content = indoc! {"
1685 ---
1686 name: full-agent
1687 description: Full featured agent
1688 model: claude-opus-4-6
1689 tools:
1690 allow:
1691 - shell
1692 except:
1693 - shell_sudo
1694 permissions:
1695 max_turns: 5
1696 background: true
1697 timeout_secs: 120
1698 ttl_secs: 60
1699 skills:
1700 include:
1701 - \"git-*\"
1702 ---
1703
1704 System prompt here.
1705 "};
1706 let def = SubAgentDef::parse(content).unwrap();
1707 let md = def.serialize_to_markdown();
1708 assert!(md.contains("model: claude-opus-4-6"));
1709 assert!(md.contains("except:"));
1710 assert!(md.contains("shell_sudo"));
1711 assert!(md.contains("background: true"));
1712 assert!(md.contains("System prompt here."));
1713 }
1714
1715 #[test]
1716 fn save_atomic_creates_file() {
1717 let dir = tempfile::tempdir().unwrap();
1718 let def = SubAgentDef::default_template("myagent", "A test agent");
1719 let path = def.save_atomic(dir.path()).unwrap();
1720 assert!(path.exists());
1721 assert_eq!(path.file_name().unwrap(), "myagent.md");
1722 let content = std::fs::read_to_string(&path).unwrap();
1723 assert!(content.contains("name: myagent"));
1724 }
1725
1726 #[test]
1727 fn save_atomic_creates_parent_dirs() {
1728 let base = tempfile::tempdir().unwrap();
1729 let nested = base.path().join("a").join("b").join("c");
1730 let def = SubAgentDef::default_template("nested", "Nested dir test");
1731 let path = def.save_atomic(&nested).unwrap();
1732 assert!(path.exists());
1733 }
1734
1735 #[test]
1736 fn save_atomic_overwrites_existing() {
1737 let dir = tempfile::tempdir().unwrap();
1738 let def1 = SubAgentDef::default_template("agent", "First description");
1739 def1.save_atomic(dir.path()).unwrap();
1740
1741 let def2 = SubAgentDef::default_template("agent", "Second description");
1742 def2.save_atomic(dir.path()).unwrap();
1743
1744 let content = std::fs::read_to_string(dir.path().join("agent.md")).unwrap();
1745 assert!(content.contains("Second description"));
1746 assert!(!content.contains("First description"));
1747 }
1748
1749 #[test]
1750 fn delete_file_removes() {
1751 let dir = tempfile::tempdir().unwrap();
1752 let def = SubAgentDef::default_template("todelete", "Will be deleted");
1753 let path = def.save_atomic(dir.path()).unwrap();
1754 assert!(path.exists());
1755 SubAgentDef::delete_file(&path).unwrap();
1756 assert!(!path.exists());
1757 }
1758
1759 #[test]
1760 fn delete_file_nonexistent_errors() {
1761 let path = std::path::PathBuf::from("/tmp/does-not-exist-zeph-test.md");
1762 let result = SubAgentDef::delete_file(&path);
1763 assert!(result.is_err());
1764 assert!(matches!(result.unwrap_err(), SubAgentError::Io { .. }));
1765 }
1766
1767 #[test]
1768 fn save_atomic_rejects_invalid_name() {
1769 let dir = tempfile::tempdir().unwrap();
1770 let mut def = SubAgentDef::default_template("valid-name", "desc");
1771 def.name = "../../etc/cron.d/agent".to_owned();
1773 let result = def.save_atomic(dir.path());
1774 assert!(result.is_err());
1775 assert!(matches!(result.unwrap_err(), SubAgentError::Invalid(_)));
1776 }
1777
1778 #[test]
1779 fn is_valid_agent_name_accepts_valid() {
1780 assert!(super::is_valid_agent_name("reviewer"));
1781 assert!(super::is_valid_agent_name("code-reviewer"));
1782 assert!(super::is_valid_agent_name("code_reviewer"));
1783 assert!(super::is_valid_agent_name("a"));
1784 assert!(super::is_valid_agent_name("A1"));
1785 }
1786
1787 #[test]
1788 fn is_valid_agent_name_rejects_invalid() {
1789 assert!(!super::is_valid_agent_name(""));
1790 assert!(!super::is_valid_agent_name("my agent"));
1791 assert!(!super::is_valid_agent_name("../../etc"));
1792 assert!(!super::is_valid_agent_name("-starts-with-dash"));
1793 assert!(!super::is_valid_agent_name("has.dot"));
1794 }
1795}