1use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13use std::sync::LazyLock;
14
15use regex::Regex;
16use serde::{Deserialize, Serialize};
17use tempfile::NamedTempFile;
18
19use super::error::SubAgentError;
20use super::hooks::SubagentHooks;
21
22pub use zeph_config::{MemoryScope, ModelSpec, PermissionMode, SkillFilter, ToolPolicy};
23
24pub(super) static AGENT_NAME_RE: LazyLock<Regex> =
27 LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$").unwrap());
28
29pub fn is_valid_agent_name(name: &str) -> bool {
49 AGENT_NAME_RE.is_match(name)
50}
51
52const MAX_DEF_SIZE: usize = 256 * 1024;
56
57const MAX_ENTRIES_PER_DIR: usize = 100;
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct SubAgentDef {
99 pub name: String,
101 pub description: String,
103 pub model: Option<ModelSpec>,
105 pub tools: ToolPolicy,
107 pub disallowed_tools: Vec<String>,
121 pub permissions: SubAgentPermissions,
123 pub skills: SkillFilter,
125 pub system_prompt: String,
127 pub hooks: SubagentHooks,
132 pub memory: Option<MemoryScope>,
135 #[serde(skip)]
141 pub source: Option<String>,
142 #[serde(skip)]
146 pub file_path: Option<PathBuf>,
147}
148
149impl SubAgentDef {
150 #[must_use]
156 pub fn for_test(name: &str) -> SubAgentDef {
157 SubAgentDef {
158 name: name.to_string(),
159 description: format!("{name} agent"),
160 model: None,
161 tools: ToolPolicy::InheritAll,
162 disallowed_tools: Vec::new(),
163 permissions: SubAgentPermissions::default(),
164 skills: SkillFilter::default(),
165 system_prompt: String::new(),
166 hooks: SubagentHooks::default(),
167 memory: None,
168 source: None,
169 file_path: None,
170 }
171 }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct SubAgentPermissions {
180 pub secrets: Vec<String>,
182 pub max_turns: u32,
184 pub background: bool,
186 pub timeout_secs: u64,
188 pub ttl_secs: u64,
190 pub permission_mode: PermissionMode,
192 pub max_history_messages: usize,
198 pub worktree: bool,
204}
205
206impl Default for SubAgentPermissions {
207 fn default() -> Self {
208 Self {
209 secrets: Vec::new(),
210 max_turns: 20,
211 background: false,
212 timeout_secs: 600,
213 ttl_secs: 300,
214 permission_mode: PermissionMode::Default,
215 max_history_messages: 200,
216 worktree: false,
217 }
218 }
219}
220
221#[derive(Deserialize)]
226#[serde(deny_unknown_fields)]
227struct RawSubAgentDef {
228 name: String,
229 description: String,
230 model: Option<ModelSpec>,
231 #[serde(default)]
232 tools: RawToolPolicy,
233 #[serde(default)]
234 permissions: RawPermissions,
235 #[serde(default)]
236 skills: RawSkillFilter,
237 #[serde(default)]
238 hooks: SubagentHooks,
239 #[serde(default)]
240 memory: Option<MemoryScope>,
241}
242
243#[derive(Default, Deserialize)]
249#[serde(deny_unknown_fields)]
250struct RawToolPolicy {
251 allow: Option<Vec<String>>,
252 deny: Option<Vec<String>>,
253 #[serde(default)]
256 except: Vec<String>,
257}
258
259#[derive(Deserialize)]
260#[serde(deny_unknown_fields)]
261struct RawPermissions {
262 #[serde(default)]
263 secrets: Vec<String>,
264 #[serde(default = "default_max_turns")]
265 max_turns: u32,
266 #[serde(default)]
267 background: bool,
268 #[serde(default = "default_timeout")]
269 timeout_secs: u64,
270 #[serde(default = "default_ttl")]
271 ttl_secs: u64,
272 #[serde(default)]
273 permission_mode: PermissionMode,
274 #[serde(default = "default_max_history_messages")]
275 max_history_messages: usize,
276 #[serde(default)]
277 worktree: bool,
278}
279
280impl Default for RawPermissions {
281 fn default() -> Self {
282 Self {
283 secrets: Vec::new(),
284 max_turns: default_max_turns(),
285 background: false,
286 timeout_secs: default_timeout(),
287 ttl_secs: default_ttl(),
288 permission_mode: PermissionMode::Default,
289 max_history_messages: default_max_history_messages(),
290 worktree: false,
291 }
292 }
293}
294
295#[derive(Default, Deserialize)]
296struct RawSkillFilter {
297 #[serde(default)]
298 include: Vec<String>,
299 #[serde(default)]
300 exclude: Vec<String>,
301}
302
303fn default_max_turns() -> u32 {
304 20
305}
306fn default_timeout() -> u64 {
307 600
308}
309fn default_ttl() -> u64 {
310 300
311}
312fn default_max_history_messages() -> usize {
313 200
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319enum FrontmatterFormat {
320 Yaml,
321 Toml,
322}
323
324fn split_frontmatter<'a>(
344 content: &'a str,
345 path: &str,
346) -> Result<(&'a str, &'a str, FrontmatterFormat), SubAgentError> {
347 let make_err = |reason: &str| SubAgentError::Parse {
348 path: path.to_owned(),
349 reason: reason.to_owned(),
350 };
351
352 if let Some(rest) = content
353 .strip_prefix("---")
354 .and_then(|s| s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n")))
355 {
356 let (yaml_str, after) = rest
361 .split_once("\n---")
362 .ok_or_else(|| make_err("missing closing `---` delimiter for YAML frontmatter"))?;
363 let body = after
364 .strip_prefix('\n')
365 .or_else(|| after.strip_prefix("\r\n"))
366 .unwrap_or(after);
367 return Ok((yaml_str, body, FrontmatterFormat::Yaml));
368 }
369
370 if let Some(rest) = content
371 .strip_prefix("+++")
372 .and_then(|s| s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n")))
373 {
374 let (toml_str, after) = rest
376 .split_once("\n+++")
377 .ok_or_else(|| make_err("missing closing `+++` delimiter for TOML frontmatter"))?;
378 let body = after
379 .strip_prefix('\n')
380 .or_else(|| after.strip_prefix("\r\n"))
381 .unwrap_or(after);
382 return Ok((toml_str, body, FrontmatterFormat::Toml));
383 }
384
385 Err(make_err(
386 "missing frontmatter delimiters: expected `---` (YAML) or `+++` (TOML, deprecated)",
387 ))
388}
389
390impl SubAgentDef {
391 pub fn parse(content: &str) -> Result<Self, SubAgentError> {
422 Self::parse_with_path(content, "<unknown>")
423 }
424
425 #[allow(clippy::too_many_lines)]
426 fn parse_with_path(content: &str, path: &str) -> Result<Self, SubAgentError> {
427 let (frontmatter_str, body, format) = split_frontmatter(content, path)?;
428
429 let raw: RawSubAgentDef = match format {
430 FrontmatterFormat::Yaml => {
431 let yaml_normalized;
433 let yaml_str = if frontmatter_str.contains('\r') {
434 yaml_normalized = frontmatter_str.replace("\r\n", "\n").replace('\r', "\n");
435 &yaml_normalized
436 } else {
437 frontmatter_str
438 };
439 serde_norway::from_str(yaml_str).map_err(|e| SubAgentError::Parse {
440 path: path.to_owned(),
441 reason: e.to_string(),
442 })?
443 }
444 FrontmatterFormat::Toml => {
445 tracing::warn!(
446 path,
447 "sub-agent definition uses deprecated +++ TOML frontmatter, migrate to --- YAML"
448 );
449 let toml_normalized;
451 let toml_str = if frontmatter_str.contains('\r') {
452 toml_normalized = frontmatter_str.replace("\r\n", "\n").replace('\r', "\n");
453 &toml_normalized
454 } else {
455 frontmatter_str
456 };
457 toml::from_str(toml_str).map_err(|e| SubAgentError::Parse {
458 path: path.to_owned(),
459 reason: e.to_string(),
460 })?
461 }
462 };
463
464 if raw.name.trim().is_empty() {
465 return Err(SubAgentError::Invalid("name must not be empty".into()));
466 }
467 if raw.description.trim().is_empty() {
468 return Err(SubAgentError::Invalid(
469 "description must not be empty".into(),
470 ));
471 }
472 if !AGENT_NAME_RE.is_match(&raw.name) {
475 return Err(SubAgentError::Invalid(format!(
476 "name '{}' is invalid: must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{{0,63}}$ \
477 (ASCII only, no spaces or special characters)",
478 raw.name
479 )));
480 }
481 if raw
482 .description
483 .chars()
484 .any(|c| (c < '\x20' && c != '\t') || c == '\x7F')
485 {
486 return Err(SubAgentError::Invalid(
487 "description must not contain control characters".into(),
488 ));
489 }
490
491 let tools = match (raw.tools.allow, raw.tools.deny) {
492 (None, None) => ToolPolicy::InheritAll,
493 (Some(list), None) => ToolPolicy::AllowList(list),
494 (None, Some(list)) => ToolPolicy::DenyList(list),
495 (Some(_), Some(_)) => {
496 return Err(SubAgentError::Invalid(
497 "tools.allow and tools.deny are mutually exclusive".into(),
498 ));
499 }
500 };
501
502 let disallowed_tools = raw.tools.except;
503
504 let p = raw.permissions;
505 if p.permission_mode == PermissionMode::BypassPermissions {
506 tracing::warn!(
507 name = %raw.name,
508 "sub-agent definition uses bypass_permissions mode — grants unrestricted tool access"
509 );
510 }
511 Ok(Self {
512 name: raw.name,
513 description: raw.description,
514 model: raw.model,
515 tools,
516 disallowed_tools,
517 permissions: SubAgentPermissions {
518 secrets: p.secrets,
519 max_turns: p.max_turns,
520 background: p.background,
521 timeout_secs: p.timeout_secs,
522 ttl_secs: p.ttl_secs,
523 permission_mode: p.permission_mode,
524 max_history_messages: p.max_history_messages,
525 worktree: p.worktree,
526 },
527 skills: SkillFilter {
528 include: raw.skills.include,
529 exclude: raw.skills.exclude,
530 },
531 hooks: raw.hooks,
532 memory: raw.memory,
533 system_prompt: body.trim().to_owned(),
534 source: None,
535 file_path: None,
536 })
537 }
538
539 pub fn load(path: &Path) -> Result<Self, SubAgentError> {
549 Self::load_with_boundary(path, None, None)
550 }
551
552 pub(crate) fn load_with_boundary(
554 path: &Path,
555 boundary: Option<&Path>,
556 scope: Option<&str>,
557 ) -> Result<Self, SubAgentError> {
558 let path_str = path.display().to_string();
559
560 let canonical = std::fs::canonicalize(path).map_err(|e| SubAgentError::Parse {
562 path: path_str.clone(),
563 reason: format!("cannot resolve path: {e}"),
564 })?;
565
566 if let Some(boundary) = boundary
568 && !canonical.starts_with(boundary)
569 {
570 return Err(SubAgentError::Parse {
571 path: path_str.clone(),
572 reason: format!(
573 "definition file escapes allowed directory boundary ({})",
574 boundary.display()
575 ),
576 });
577 }
578
579 let content = std::fs::read_to_string(&canonical).map_err(|e| SubAgentError::Parse {
580 path: path_str.clone(),
581 reason: e.to_string(),
582 })?;
583 if content.len() > MAX_DEF_SIZE {
584 return Err(SubAgentError::Parse {
585 path: path_str.clone(),
586 reason: format!(
587 "definition file exceeds maximum size of {} KiB",
588 MAX_DEF_SIZE / 1024
589 ),
590 });
591 }
592 let mut def = Self::parse_with_path(&content, &path_str)?;
593
594 if scope == Some("user") {
598 if !def.hooks.pre_tool_use.is_empty() || !def.hooks.post_tool_use.is_empty() {
599 tracing::warn!(
600 path = %path_str,
601 "user-level agent definition contains hooks — stripping for security"
602 );
603 }
604 def.hooks = SubagentHooks::default();
605 }
606
607 let filename = path
609 .file_name()
610 .and_then(|f| f.to_str())
611 .unwrap_or("<unknown>");
612 def.source = Some(if let Some(scope) = scope {
613 format!("{scope}/{filename}")
614 } else {
615 filename.to_owned()
616 });
617 def.file_path = Some(canonical);
619
620 Ok(def)
621 }
622
623 pub fn load_all(paths: &[PathBuf]) -> Result<Vec<Self>, SubAgentError> {
636 Self::load_all_with_sources(paths, &[], None, &[])
637 }
638
639 pub fn load_all_with_sources(
649 ordered_paths: &[PathBuf],
650 cli_agents: &[PathBuf],
651 config_user_dir: Option<&PathBuf>,
652 extra_dirs: &[PathBuf],
653 ) -> Result<Vec<Self>, SubAgentError> {
654 let mut seen: HashSet<String> = HashSet::new();
655 let mut result = Vec::new();
656
657 for path in ordered_paths {
658 if path.is_file() {
659 let is_cli = cli_agents.iter().any(|c| c == path);
663 match Self::load_with_boundary(path, None, Some("cli")) {
664 Ok(def) => {
665 if seen.contains(&def.name) {
666 tracing::debug!(
667 name = %def.name,
668 path = %path.display(),
669 "skipping duplicate sub-agent definition"
670 );
671 } else {
672 seen.insert(def.name.clone());
673 result.push(def);
674 }
675 }
676 Err(e) if is_cli => return Err(e),
677 Err(e) => {
678 tracing::warn!(path = %path.display(), error = %e, "skipping malformed agent definition");
679 }
680 }
681 continue;
682 }
683
684 let Ok(read_dir) = std::fs::read_dir(path) else {
685 continue; };
687
688 let is_cli_dir = cli_agents.iter().any(|c| c == path);
692 let boundary = if is_cli_dir {
693 None
694 } else {
695 std::fs::canonicalize(path).ok()
699 };
700
701 let scope = super::resolve::scope_label(path, cli_agents, config_user_dir, extra_dirs);
702 let is_cli_scope = is_cli_dir;
703
704 let mut entries: Vec<PathBuf> = read_dir
705 .filter_map(std::result::Result::ok)
706 .map(|e| e.path())
707 .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
708 .collect();
709
710 entries.sort(); if entries.len() > MAX_ENTRIES_PER_DIR {
713 tracing::warn!(
714 dir = %path.display(),
715 count = entries.len(),
716 cap = MAX_ENTRIES_PER_DIR,
717 "agent directory exceeds entry cap; processing only first {MAX_ENTRIES_PER_DIR} files"
718 );
719 entries.truncate(MAX_ENTRIES_PER_DIR);
720 }
721
722 for entry_path in entries {
723 let load_result =
724 Self::load_with_boundary(&entry_path, boundary.as_deref(), Some(scope));
725
726 let def = match load_result {
727 Ok(d) => d,
728 Err(e) if is_cli_scope => return Err(e),
729 Err(e) => {
730 tracing::warn!(
731 path = %entry_path.display(),
732 error = %e,
733 "skipping malformed agent definition"
734 );
735 continue;
736 }
737 };
738
739 if seen.contains(&def.name) {
740 tracing::debug!(
741 name = %def.name,
742 path = %entry_path.display(),
743 "skipping duplicate sub-agent definition (shadowed by higher-priority path)"
744 );
745 continue;
746 }
747 seen.insert(def.name.clone());
748 result.push(def);
749 }
750 }
751
752 Ok(result)
753 }
754}
755
756#[derive(Serialize)]
761struct WritableRawDef<'a> {
762 name: &'a str,
763 description: &'a str,
764 #[serde(skip_serializing_if = "Option::is_none")]
765 model: Option<&'a ModelSpec>,
766 #[serde(skip_serializing_if = "WritableToolPolicy::is_inherit_all")]
767 tools: WritableToolPolicy<'a>,
768 #[serde(skip_serializing_if = "WritablePermissions::is_default")]
769 permissions: WritablePermissions<'a>,
770 #[serde(skip_serializing_if = "SkillFilter::is_empty")]
771 skills: &'a SkillFilter,
772 #[serde(skip_serializing_if = "SubagentHooks::is_empty")]
773 hooks: &'a SubagentHooks,
774 #[serde(skip_serializing_if = "Option::is_none")]
775 memory: Option<MemoryScope>,
776}
777
778#[derive(Serialize)]
779struct WritableToolPolicy<'a> {
780 #[serde(skip_serializing_if = "Option::is_none")]
781 allow: Option<&'a Vec<String>>,
782 #[serde(skip_serializing_if = "Option::is_none")]
783 deny: Option<&'a Vec<String>>,
784 #[serde(skip_serializing_if = "Vec::is_empty")]
785 except: &'a Vec<String>,
786}
787
788impl<'a> WritableToolPolicy<'a> {
789 fn from_def(policy: &'a ToolPolicy, except: &'a Vec<String>) -> Self {
790 match policy {
791 ToolPolicy::AllowList(v) => Self {
792 allow: Some(v),
793 deny: None,
794 except,
795 },
796 ToolPolicy::DenyList(v) => Self {
797 allow: None,
798 deny: Some(v),
799 except,
800 },
801 _ => Self {
802 allow: None,
803 deny: None,
804 except,
805 },
806 }
807 }
808
809 fn is_inherit_all(&self) -> bool {
810 self.allow.is_none() && self.deny.is_none() && self.except.is_empty()
811 }
812}
813
814#[derive(Serialize)]
815struct WritablePermissions<'a> {
816 #[serde(skip_serializing_if = "Vec::is_empty")]
817 secrets: &'a Vec<String>,
818 max_turns: u32,
819 background: bool,
820 timeout_secs: u64,
821 ttl_secs: u64,
822 permission_mode: PermissionMode,
823 #[serde(skip_serializing_if = "std::ops::Not::not")]
824 worktree: bool,
825}
826
827impl<'a> WritablePermissions<'a> {
828 fn from_def(p: &'a SubAgentPermissions) -> Self {
829 Self {
830 secrets: &p.secrets,
831 max_turns: p.max_turns,
832 background: p.background,
833 timeout_secs: p.timeout_secs,
834 ttl_secs: p.ttl_secs,
835 permission_mode: p.permission_mode,
836 worktree: p.worktree,
837 }
838 }
839
840 fn is_default(&self) -> bool {
841 self.secrets.is_empty()
842 && self.max_turns == default_max_turns()
843 && !self.background
844 && self.timeout_secs == default_timeout()
845 && self.ttl_secs == default_ttl()
846 && self.permission_mode == PermissionMode::Default
847 }
848}
849
850impl SubAgentDef {
851 #[must_use]
860 pub fn serialize_to_markdown(&self) -> String {
861 let tools = WritableToolPolicy::from_def(&self.tools, &self.disallowed_tools);
862 let permissions = WritablePermissions::from_def(&self.permissions);
863
864 let writable = WritableRawDef {
865 name: &self.name,
866 description: &self.description,
867 model: self.model.as_ref(),
868 tools,
869 permissions,
870 skills: &self.skills,
871 hooks: &self.hooks,
872 memory: self.memory,
873 };
874
875 let yaml = serde_norway::to_string(&writable).expect("serialization cannot fail");
876 if self.system_prompt.is_empty() {
877 format!("---\n{yaml}---\n")
878 } else {
879 format!("---\n{yaml}---\n\n{}\n", self.system_prompt)
880 }
881 }
882
883 pub fn save_atomic(&self, dir: &Path) -> Result<PathBuf, SubAgentError> {
893 if !AGENT_NAME_RE.is_match(&self.name) {
894 return Err(SubAgentError::Invalid(format!(
895 "name '{}' is invalid: must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{{0,63}}$",
896 self.name
897 )));
898 }
899 std::fs::create_dir_all(dir).map_err(|e| SubAgentError::Io {
900 path: dir.display().to_string(),
901 reason: format!("cannot create directory: {e}"),
902 })?;
903
904 let content = self.serialize_to_markdown();
905 let target = dir.join(format!("{}.md", self.name));
906
907 let mut tmp = NamedTempFile::new_in(dir).map_err(|e| SubAgentError::Io {
908 path: dir.display().to_string(),
909 reason: format!("cannot create temp file: {e}"),
910 })?;
911
912 std::io::Write::write_all(&mut tmp, content.as_bytes()).map_err(|e| SubAgentError::Io {
913 path: dir.display().to_string(),
914 reason: format!("cannot write temp file: {e}"),
915 })?;
916
917 tmp.persist(&target).map_err(|e| SubAgentError::Io {
918 path: target.display().to_string(),
919 reason: format!("cannot rename temp file: {e}"),
920 })?;
921
922 Ok(target)
923 }
924
925 pub fn delete_file(path: &Path) -> Result<(), SubAgentError> {
931 std::fs::remove_file(path).map_err(|e| SubAgentError::Io {
932 path: path.display().to_string(),
933 reason: e.to_string(),
934 })
935 }
936
937 #[must_use]
941 pub fn default_template(name: impl Into<String>, description: impl Into<String>) -> Self {
942 Self {
943 name: name.into(),
944 description: description.into(),
945 model: None,
946 tools: ToolPolicy::InheritAll,
947 disallowed_tools: Vec::new(),
948 permissions: SubAgentPermissions::default(),
949 skills: SkillFilter::default(),
950 hooks: SubagentHooks::default(),
951 memory: None,
952 system_prompt: String::new(),
953 source: None,
954 file_path: None,
955 }
956 }
957}
958
959#[cfg(test)]
962mod tests {
963 #![allow(clippy::cloned_ref_to_slice_refs)]
964 use std::assert_matches;
965
966 use indoc::indoc;
967
968 use super::*;
969
970 const FULL_DEF_YAML: &str = indoc! {"
973 ---
974 name: code-reviewer
975 description: Reviews code changes for correctness and style
976 model: claude-sonnet-4-20250514
977 tools:
978 allow:
979 - shell
980 - web_scrape
981 permissions:
982 secrets:
983 - github-token
984 max_turns: 10
985 background: false
986 timeout_secs: 300
987 ttl_secs: 120
988 skills:
989 include:
990 - \"git-*\"
991 - \"rust-*\"
992 exclude:
993 - \"deploy-*\"
994 ---
995
996 You are a code reviewer. Report findings with severity.
997 "};
998
999 const MINIMAL_DEF_YAML: &str = indoc! {"
1000 ---
1001 name: bot
1002 description: A bot
1003 ---
1004
1005 Do things.
1006 "};
1007
1008 const FULL_DEF_TOML: &str = indoc! {"
1011 +++
1012 name = \"code-reviewer\"
1013 description = \"Reviews code changes for correctness and style\"
1014 model = \"claude-sonnet-4-20250514\"
1015
1016 [tools]
1017 allow = [\"shell\", \"web_scrape\"]
1018
1019 [permissions]
1020 secrets = [\"github-token\"]
1021 max_turns = 10
1022 background = false
1023 timeout_secs = 300
1024 ttl_secs = 120
1025
1026 [skills]
1027 include = [\"git-*\", \"rust-*\"]
1028 exclude = [\"deploy-*\"]
1029 +++
1030
1031 You are a code reviewer. Report findings with severity.
1032 "};
1033
1034 const MINIMAL_DEF_TOML: &str = indoc! {"
1035 +++
1036 name = \"bot\"
1037 description = \"A bot\"
1038 +++
1039
1040 Do things.
1041 "};
1042
1043 #[test]
1046 fn parse_yaml_full_definition() {
1047 let def = SubAgentDef::parse(FULL_DEF_YAML).unwrap();
1048 assert_eq!(def.name, "code-reviewer");
1049 assert_eq!(
1050 def.description,
1051 "Reviews code changes for correctness and style"
1052 );
1053 assert_eq!(
1054 def.model,
1055 Some(ModelSpec::Named("claude-sonnet-4-20250514".to_owned()))
1056 );
1057 assert_matches!(def.tools, ToolPolicy::AllowList(ref v) if v == &["shell", "web_scrape"]);
1058 assert_eq!(def.permissions.max_turns, 10);
1059 assert_eq!(def.permissions.secrets, ["github-token"]);
1060 assert_eq!(def.skills.include, ["git-*", "rust-*"]);
1061 assert_eq!(def.skills.exclude, ["deploy-*"]);
1062 assert!(def.system_prompt.contains("code reviewer"));
1063 }
1064
1065 #[test]
1066 fn parse_yaml_minimal_definition() {
1067 let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1068 assert_eq!(def.name, "bot");
1069 assert_eq!(def.description, "A bot");
1070 assert!(def.model.is_none());
1071 assert_matches!(def.tools, ToolPolicy::InheritAll);
1072 assert_eq!(def.permissions.max_turns, 20);
1073 assert_eq!(def.permissions.timeout_secs, 600);
1074 assert_eq!(def.permissions.ttl_secs, 300);
1075 assert!(!def.permissions.background);
1076 assert_eq!(def.system_prompt, "Do things.");
1077 }
1078
1079 #[test]
1080 fn parse_yaml_with_dashes_in_body() {
1081 let content = "---\nname: agent\ndescription: desc\n---\n\nSome text\n---\nMore text\n";
1083 let def = SubAgentDef::parse(content).unwrap();
1084 assert_eq!(def.name, "agent");
1085 assert!(def.system_prompt.contains("Some text"));
1086 assert!(def.system_prompt.contains("More text"));
1087 }
1088
1089 #[test]
1090 fn parse_yaml_tool_deny_list() {
1091 let content = "---\nname: a\ndescription: b\ntools:\n deny:\n - shell\n---\n\nbody\n";
1092 let def = SubAgentDef::parse(content).unwrap();
1093 assert_matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["shell"]);
1094 }
1095
1096 #[test]
1097 fn parse_yaml_tool_inherit_all() {
1098 let content = "---\nname: a\ndescription: b\ntools: {}\n---\n\nbody\n";
1100 let def = SubAgentDef::parse(content).unwrap();
1101 assert_matches!(def.tools, ToolPolicy::InheritAll);
1102 }
1103
1104 #[test]
1105 fn parse_yaml_tool_both_specified_is_error() {
1106 let content = "---\nname: a\ndescription: b\ntools:\n allow:\n - x\n deny:\n - y\n---\n\nbody\n";
1107 let err = SubAgentDef::parse(content).unwrap_err();
1108 assert_matches!(err, SubAgentError::Invalid(_));
1109 }
1110
1111 #[test]
1112 fn parse_yaml_missing_closing_delimiter() {
1113 let err = SubAgentDef::parse("---\nname: a\ndescription: b\n").unwrap_err();
1114 assert_matches!(err, SubAgentError::Parse { .. });
1115 }
1116
1117 #[test]
1118 fn parse_yaml_crlf_line_endings() {
1119 let content = "---\r\nname: bot\r\ndescription: A bot\r\n---\r\n\r\nDo things.\r\n";
1120 let def = SubAgentDef::parse(content).unwrap();
1121 assert_eq!(def.name, "bot");
1122 assert_eq!(def.description, "A bot");
1123 assert!(!def.system_prompt.is_empty());
1124 }
1125
1126 #[test]
1127 fn parse_yaml_missing_required_field_name() {
1128 let content = "---\ndescription: b\n---\n\nbody\n";
1129 let err = SubAgentDef::parse(content).unwrap_err();
1130 assert_matches!(err, SubAgentError::Parse { .. });
1131 }
1132
1133 #[test]
1134 fn parse_yaml_missing_required_field_description() {
1135 let content = "---\nname: a\n---\n\nbody\n";
1136 let err = SubAgentDef::parse(content).unwrap_err();
1137 assert_matches!(err, SubAgentError::Parse { .. });
1138 }
1139
1140 #[test]
1141 fn parse_yaml_empty_name_is_invalid() {
1142 let content = "---\nname: \"\"\ndescription: b\n---\n\nbody\n";
1143 let err = SubAgentDef::parse(content).unwrap_err();
1144 assert_matches!(err, SubAgentError::Invalid(_));
1145 }
1146
1147 #[test]
1148 fn parse_yaml_whitespace_only_description_is_invalid() {
1149 let content = "---\nname: a\ndescription: \" \"\n---\n\nbody\n";
1150 let err = SubAgentDef::parse(content).unwrap_err();
1151 assert_matches!(err, SubAgentError::Invalid(_));
1152 }
1153
1154 #[test]
1155 fn parse_yaml_crlf_with_numeric_fields() {
1156 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";
1157 let def = SubAgentDef::parse(content).unwrap();
1158 assert_eq!(def.permissions.max_turns, 5);
1159 assert_eq!(def.permissions.timeout_secs, 120);
1160 }
1161
1162 #[test]
1163 fn parse_yaml_no_trailing_newline() {
1164 let content = "---\nname: a\ndescription: b\n---";
1165 let def = SubAgentDef::parse(content).unwrap();
1166 assert_eq!(def.system_prompt, "");
1167 }
1168
1169 #[test]
1172 fn parse_full_definition() {
1173 let def = SubAgentDef::parse(FULL_DEF_TOML).unwrap();
1174 assert_eq!(def.name, "code-reviewer");
1175 assert_eq!(
1176 def.description,
1177 "Reviews code changes for correctness and style"
1178 );
1179 assert_eq!(
1180 def.model,
1181 Some(ModelSpec::Named("claude-sonnet-4-20250514".to_owned()))
1182 );
1183 assert_matches!(def.tools, ToolPolicy::AllowList(ref v) if v == &["shell", "web_scrape"]);
1184 assert_eq!(def.permissions.max_turns, 10);
1185 assert_eq!(def.permissions.secrets, ["github-token"]);
1186 assert_eq!(def.skills.include, ["git-*", "rust-*"]);
1187 assert_eq!(def.skills.exclude, ["deploy-*"]);
1188 assert!(def.system_prompt.contains("code reviewer"));
1189 }
1190
1191 #[test]
1192 fn parse_minimal_definition() {
1193 let def = SubAgentDef::parse(MINIMAL_DEF_TOML).unwrap();
1194 assert_eq!(def.name, "bot");
1195 assert_eq!(def.description, "A bot");
1196 assert!(def.model.is_none());
1197 assert_matches!(def.tools, ToolPolicy::InheritAll);
1198 assert_eq!(def.permissions.max_turns, 20);
1199 assert_eq!(def.permissions.timeout_secs, 600);
1200 assert_eq!(def.permissions.ttl_secs, 300);
1201 assert!(!def.permissions.background);
1202 assert_eq!(def.system_prompt, "Do things.");
1203 }
1204
1205 #[test]
1206 fn tool_policy_deny_list() {
1207 let content =
1208 "+++\nname = \"a\"\ndescription = \"b\"\n[tools]\ndeny = [\"shell\"]\n+++\n\nbody\n";
1209 let def = SubAgentDef::parse(content).unwrap();
1210 assert_matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["shell"]);
1211 }
1212
1213 #[test]
1214 fn tool_policy_inherit_all() {
1215 let def = SubAgentDef::parse(MINIMAL_DEF_TOML).unwrap();
1216 assert_matches!(def.tools, ToolPolicy::InheritAll);
1217 }
1218
1219 #[test]
1220 fn tool_policy_both_specified_is_error() {
1221 let content = "+++\nname = \"a\"\ndescription = \"b\"\n[tools]\nallow = [\"x\"]\ndeny = [\"y\"]\n+++\n\nbody\n";
1222 let err = SubAgentDef::parse(content).unwrap_err();
1223 assert_matches!(err, SubAgentError::Invalid(_));
1224 }
1225
1226 #[test]
1227 fn missing_opening_delimiter() {
1228 let err = SubAgentDef::parse("name = \"a\"\n+++\nbody\n").unwrap_err();
1229 assert_matches!(err, SubAgentError::Parse { .. });
1230 }
1231
1232 #[test]
1233 fn missing_closing_delimiter() {
1234 let err = SubAgentDef::parse("+++\nname = \"a\"\ndescription = \"b\"\n").unwrap_err();
1235 assert_matches!(err, SubAgentError::Parse { .. });
1236 }
1237
1238 #[test]
1239 fn missing_required_field_name() {
1240 let content = "+++\ndescription = \"b\"\n+++\n\nbody\n";
1241 let err = SubAgentDef::parse(content).unwrap_err();
1242 assert_matches!(err, SubAgentError::Parse { .. });
1243 }
1244
1245 #[test]
1246 fn missing_required_field_description() {
1247 let content = "+++\nname = \"a\"\n+++\n\nbody\n";
1248 let err = SubAgentDef::parse(content).unwrap_err();
1249 assert_matches!(err, SubAgentError::Parse { .. });
1250 }
1251
1252 #[test]
1253 fn empty_name_is_invalid() {
1254 let content = "+++\nname = \"\"\ndescription = \"b\"\n+++\n\nbody\n";
1255 let err = SubAgentDef::parse(content).unwrap_err();
1256 assert_matches!(err, SubAgentError::Invalid(_));
1257 }
1258
1259 #[test]
1260 fn load_all_deduplication_by_name() {
1261 use std::io::Write as _;
1262 let dir1 = tempfile::tempdir().unwrap();
1263 let dir2 = tempfile::tempdir().unwrap();
1264
1265 let content1 = "---\nname: bot\ndescription: from dir1\n---\n\ndir1 prompt\n";
1266 let content2 = "---\nname: bot\ndescription: from dir2\n---\n\ndir2 prompt\n";
1267
1268 let mut f1 = std::fs::File::create(dir1.path().join("bot.md")).unwrap();
1269 f1.write_all(content1.as_bytes()).unwrap();
1270
1271 let mut f2 = std::fs::File::create(dir2.path().join("bot.md")).unwrap();
1272 f2.write_all(content2.as_bytes()).unwrap();
1273
1274 let search_dirs = vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()];
1275 let defs = SubAgentDef::load_all(&search_dirs).unwrap();
1276
1277 assert_eq!(defs.len(), 1);
1278 assert_eq!(defs[0].description, "from dir1");
1279 }
1280
1281 #[test]
1282 fn default_permissions_values() {
1283 let p = SubAgentPermissions::default();
1284 assert_eq!(p.max_turns, 20);
1285 assert_eq!(p.timeout_secs, 600);
1286 assert_eq!(p.ttl_secs, 300);
1287 assert!(!p.background);
1288 assert!(p.secrets.is_empty());
1289 }
1290
1291 #[test]
1292 fn whitespace_only_description_is_invalid() {
1293 let content = "+++\nname = \"a\"\ndescription = \" \"\n+++\n\nbody\n";
1294 let err = SubAgentDef::parse(content).unwrap_err();
1295 assert_matches!(err, SubAgentError::Invalid(_));
1296 }
1297
1298 #[test]
1299 fn load_nonexistent_file_returns_parse_error() {
1300 let err =
1301 SubAgentDef::load(std::path::Path::new("/tmp/does-not-exist-zeph.md")).unwrap_err();
1302 assert_matches!(err, SubAgentError::Parse { .. });
1303 }
1304
1305 #[test]
1306 fn parse_crlf_line_endings() {
1307 let content =
1308 "+++\r\nname = \"bot\"\r\ndescription = \"A bot\"\r\n+++\r\n\r\nDo things.\r\n";
1309 let def = SubAgentDef::parse(content).unwrap();
1310 assert_eq!(def.name, "bot");
1311 assert_eq!(def.description, "A bot");
1312 assert!(!def.system_prompt.is_empty());
1313 }
1314
1315 #[test]
1316 fn parse_crlf_closing_delimiter() {
1317 let content = "+++\r\nname = \"bot\"\r\ndescription = \"A bot\"\r\n+++\r\nPrompt here.\r\n";
1318 let def = SubAgentDef::parse(content).unwrap();
1319 assert!(def.system_prompt.contains("Prompt here"));
1320 }
1321
1322 #[test]
1323 fn load_all_warn_and_skip_on_parse_error_for_non_cli_source() {
1324 use std::io::Write as _;
1325 let dir = tempfile::tempdir().unwrap();
1326
1327 let valid = "---\nname: good\ndescription: ok\n---\n\nbody\n";
1328 let invalid = "this is not valid frontmatter";
1329
1330 let mut f1 = std::fs::File::create(dir.path().join("a_good.md")).unwrap();
1331 f1.write_all(valid.as_bytes()).unwrap();
1332
1333 let mut f2 = std::fs::File::create(dir.path().join("b_bad.md")).unwrap();
1334 f2.write_all(invalid.as_bytes()).unwrap();
1335
1336 let defs = SubAgentDef::load_all(&[dir.path().to_path_buf()]).unwrap();
1338 assert_eq!(defs.len(), 1);
1339 assert_eq!(defs[0].name, "good");
1340 }
1341
1342 #[test]
1343 fn load_all_with_sources_hard_error_for_cli_file() {
1344 use std::io::Write as _;
1345 let dir = tempfile::tempdir().unwrap();
1346
1347 let invalid = "this is not valid frontmatter";
1348 let bad_path = dir.path().join("bad.md");
1349 let mut f = std::fs::File::create(&bad_path).unwrap();
1350 f.write_all(invalid.as_bytes()).unwrap();
1351
1352 let err = SubAgentDef::load_all_with_sources(
1354 std::slice::from_ref(&bad_path),
1355 std::slice::from_ref(&bad_path),
1356 None,
1357 &[],
1358 )
1359 .unwrap_err();
1360 assert_matches!(err, SubAgentError::Parse { .. });
1361 }
1362
1363 #[test]
1364 fn load_all_with_sources_max_entries_per_dir_cap() {
1365 let dir = tempfile::tempdir().unwrap();
1367 let total = MAX_ENTRIES_PER_DIR + 10;
1368 for i in 0..total {
1369 let content =
1370 format!("---\nname: agent-{i:04}\ndescription: Agent {i}\n---\n\nBody {i}\n");
1371 std::fs::write(dir.path().join(format!("agent-{i:04}.md")), &content).unwrap();
1372 }
1373 let defs = SubAgentDef::load_all(&[dir.path().to_path_buf()]).unwrap();
1374 assert_eq!(
1375 defs.len(),
1376 MAX_ENTRIES_PER_DIR,
1377 "must cap at MAX_ENTRIES_PER_DIR=100"
1378 );
1379 }
1380
1381 #[test]
1382 fn load_with_boundary_rejects_symlink_escape() {
1383 let dir_a = tempfile::tempdir().unwrap();
1386 let dir_b = tempfile::tempdir().unwrap();
1387
1388 let real_file = dir_b.path().join("agent.md");
1389 std::fs::write(
1390 &real_file,
1391 "---\nname: escape\ndescription: Escaped\n---\n\nBody\n",
1392 )
1393 .unwrap();
1394
1395 #[cfg(not(unix))]
1396 {
1397 let _ = (dir_a, dir_b, real_file);
1399 return;
1400 }
1401
1402 #[cfg(unix)]
1403 {
1404 let link_path = dir_a.path().join("agent.md");
1405 std::os::unix::fs::symlink(&real_file, &link_path).unwrap();
1406 let boundary = std::fs::canonicalize(dir_a.path()).unwrap();
1407 let err =
1408 SubAgentDef::load_with_boundary(&link_path, Some(&boundary), None).unwrap_err();
1409 assert!(
1410 matches!(&err, SubAgentError::Parse { reason, .. } if reason.contains("escapes allowed directory boundary")),
1411 "expected boundary violation error, got: {err}"
1412 );
1413 }
1414 }
1415
1416 #[test]
1417 fn load_all_with_sources_source_field_has_correct_scope_label() {
1418 use std::io::Write as _;
1419 let user_dir = tempfile::tempdir().unwrap();
1421 let user_dir_path = user_dir.path().to_path_buf();
1422 let content = "---\nname: my-agent\ndescription: test\n---\n\nBody\n";
1423 let mut f = std::fs::File::create(user_dir_path.join("my-agent.md")).unwrap();
1424 f.write_all(content.as_bytes()).unwrap();
1425
1426 let paths = vec![user_dir_path.clone()];
1428 let defs =
1429 SubAgentDef::load_all_with_sources(&paths, &[], Some(&user_dir_path), &[]).unwrap();
1430
1431 assert_eq!(defs.len(), 1);
1432 let source = defs[0].source.as_deref().unwrap_or("");
1433 assert!(
1434 source.starts_with("user/"),
1435 "expected source to start with 'user/', got: {source}"
1436 );
1437 }
1438
1439 #[test]
1440 fn load_all_with_sources_priority_first_name_wins() {
1441 use std::io::Write as _;
1442 let dir1 = tempfile::tempdir().unwrap();
1443 let dir2 = tempfile::tempdir().unwrap();
1444
1445 let content1 = "---\nname: bot\ndescription: from dir1\n---\n\ndir1 prompt\n";
1447 let content2 = "---\nname: bot\ndescription: from dir2\n---\n\ndir2 prompt\n";
1448
1449 let mut f1 = std::fs::File::create(dir1.path().join("bot.md")).unwrap();
1450 f1.write_all(content1.as_bytes()).unwrap();
1451 let mut f2 = std::fs::File::create(dir2.path().join("bot.md")).unwrap();
1452 f2.write_all(content2.as_bytes()).unwrap();
1453
1454 let paths = vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()];
1456 let defs = SubAgentDef::load_all_with_sources(&paths, &[], None, &[]).unwrap();
1457
1458 assert_eq!(defs.len(), 1, "name collision: only first wins");
1459 assert_eq!(defs[0].description, "from dir1");
1460 }
1461
1462 #[test]
1463 fn load_all_with_sources_user_agents_dir_none_skips_gracefully() {
1464 let dir = tempfile::tempdir().unwrap();
1467 let content = "---\nname: ok\ndescription: fine\n---\n\nBody\n";
1468 std::fs::write(dir.path().join("ok.md"), content).unwrap();
1469
1470 let paths = vec![dir.path().to_path_buf()];
1472 let defs = SubAgentDef::load_all_with_sources(&paths, &[], None, &[]).unwrap();
1473 assert_eq!(defs.len(), 1);
1474 assert_eq!(defs[0].name, "ok");
1475 }
1476
1477 #[test]
1480 fn parse_yaml_permission_mode_default_when_omitted() {
1481 let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1482 assert_eq!(def.permissions.permission_mode, PermissionMode::Default);
1483 }
1484
1485 #[test]
1486 fn parse_yaml_permission_mode_dont_ask() {
1487 let content = "---\nname: a\ndescription: b\npermissions:\n permission_mode: dont_ask\n---\n\nbody\n";
1488 let def = SubAgentDef::parse(content).unwrap();
1489 assert_eq!(def.permissions.permission_mode, PermissionMode::DontAsk);
1490 }
1491
1492 #[test]
1493 fn parse_yaml_permission_mode_accept_edits() {
1494 let content = "---\nname: a\ndescription: b\npermissions:\n permission_mode: accept_edits\n---\n\nbody\n";
1495 let def = SubAgentDef::parse(content).unwrap();
1496 assert_eq!(def.permissions.permission_mode, PermissionMode::AcceptEdits);
1497 }
1498
1499 #[test]
1500 fn parse_yaml_permission_mode_bypass_permissions() {
1501 let content = "---\nname: a\ndescription: b\npermissions:\n permission_mode: bypass_permissions\n---\n\nbody\n";
1502 let def = SubAgentDef::parse(content).unwrap();
1503 assert_eq!(
1504 def.permissions.permission_mode,
1505 PermissionMode::BypassPermissions
1506 );
1507 }
1508
1509 #[test]
1510 fn parse_yaml_permission_mode_plan() {
1511 let content =
1512 "---\nname: a\ndescription: b\npermissions:\n permission_mode: plan\n---\n\nbody\n";
1513 let def = SubAgentDef::parse(content).unwrap();
1514 assert_eq!(def.permissions.permission_mode, PermissionMode::Plan);
1515 }
1516
1517 #[test]
1518 fn parse_yaml_disallowed_tools_from_except() {
1519 let content = "---\nname: a\ndescription: b\ntools:\n allow:\n - shell\n - web\n except:\n - shell\n---\n\nbody\n";
1520 let def = SubAgentDef::parse(content).unwrap();
1521 assert!(
1522 matches!(def.tools, ToolPolicy::AllowList(ref v) if v.contains(&"shell".to_owned()))
1523 );
1524 assert_eq!(def.disallowed_tools, ["shell"]);
1525 }
1526
1527 #[test]
1528 fn parse_yaml_disallowed_tools_empty_when_no_except() {
1529 let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1530 assert!(def.disallowed_tools.is_empty());
1531 }
1532
1533 #[test]
1534 fn parse_yaml_all_new_fields_together() {
1535 let content = indoc! {"
1536 ---
1537 name: planner
1538 description: Plans things
1539 tools:
1540 allow:
1541 - shell
1542 - web
1543 except:
1544 - dangerous
1545 permissions:
1546 max_turns: 5
1547 background: true
1548 permission_mode: plan
1549 ---
1550
1551 You are a planner.
1552 "};
1553 let def = SubAgentDef::parse(content).unwrap();
1554 assert_eq!(def.permissions.permission_mode, PermissionMode::Plan);
1555 assert!(def.permissions.background);
1556 assert_eq!(def.permissions.max_turns, 5);
1557 assert_eq!(def.disallowed_tools, ["dangerous"]);
1558 }
1559
1560 #[test]
1561 fn default_permissions_includes_permission_mode_default() {
1562 let p = SubAgentPermissions::default();
1563 assert_eq!(p.permission_mode, PermissionMode::Default);
1564 }
1565
1566 #[test]
1569 fn parse_yaml_unknown_permission_mode_variant_is_error() {
1570 let content = "---\nname: a\ndescription: b\npermissions:\n permission_mode: banana_mode\n---\n\nbody\n";
1572 let err = SubAgentDef::parse(content).unwrap_err();
1573 assert_matches!(err, SubAgentError::Parse { .. });
1574 }
1575
1576 #[test]
1577 fn parse_yaml_permission_mode_case_sensitive_camel_is_error() {
1578 let content =
1580 "---\nname: a\ndescription: b\npermissions:\n permission_mode: DontAsk\n---\n\nbody\n";
1581 let err = SubAgentDef::parse(content).unwrap_err();
1582 assert_matches!(err, SubAgentError::Parse { .. });
1583 }
1584
1585 #[test]
1586 fn parse_yaml_explicit_empty_except_gives_empty_disallowed_tools() {
1587 let content = "---\nname: a\ndescription: b\ntools:\n allow:\n - shell\n except: []\n---\n\nbody\n";
1588 let def = SubAgentDef::parse(content).unwrap();
1589 assert!(def.disallowed_tools.is_empty());
1590 }
1591
1592 #[test]
1593 fn parse_yaml_disallowed_tools_with_deny_list_deny_wins() {
1594 let content = "---\nname: a\ndescription: b\ntools:\n deny:\n - dangerous\n except:\n - web\n---\n\nbody\n";
1597 let def = SubAgentDef::parse(content).unwrap();
1598 assert_matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["dangerous"]);
1600 assert!(def.disallowed_tools.contains(&"web".to_owned()));
1602 }
1603
1604 #[test]
1605 fn parse_toml_background_true_frontmatter() {
1606 let content = "+++\nname = \"bg-agent\"\ndescription = \"Runs in background\"\n[permissions]\nbackground = true\n+++\n\nSystem prompt.\n";
1608 let def = SubAgentDef::parse(content).unwrap();
1609 assert!(def.permissions.background);
1610 assert_eq!(def.name, "bg-agent");
1611 }
1612
1613 #[test]
1614 fn parse_yaml_unknown_top_level_field_is_error() {
1615 let content = "---\nname: a\ndescription: b\npermisions:\n max_turns: 5\n---\n\nbody\n";
1617 let err = SubAgentDef::parse(content).unwrap_err();
1618 assert_matches!(err, SubAgentError::Parse { .. });
1619 }
1620
1621 #[test]
1622 fn parse_yaml_unknown_permissions_field_is_error() {
1623 let content = "---\nname: a\ndescription: b\npermissions:\n pemission_mode: bypass_permissions\n---\n\nbody\n";
1626 let err = SubAgentDef::parse(content).unwrap_err();
1627 assert_matches!(err, SubAgentError::Parse { .. });
1628 }
1629
1630 #[test]
1631 fn parse_yaml_unknown_tools_field_is_error() {
1632 let content = "---\nname: a\ndescription: b\ntools:\n alow:\n - shell\n---\n\nbody\n";
1635 let err = SubAgentDef::parse(content).unwrap_err();
1636 assert_matches!(err, SubAgentError::Parse { .. });
1637 }
1638
1639 #[test]
1642 fn parse_yaml_memory_scope_project() {
1643 let content =
1644 "---\nname: reviewer\ndescription: A reviewer\nmemory: project\n---\n\nBody.\n";
1645 let def = SubAgentDef::parse(content).unwrap();
1646 assert_eq!(def.memory, Some(MemoryScope::Project));
1647 }
1648
1649 #[test]
1650 fn parse_yaml_memory_scope_user() {
1651 let content = "---\nname: reviewer\ndescription: A reviewer\nmemory: user\n---\n\nBody.\n";
1652 let def = SubAgentDef::parse(content).unwrap();
1653 assert_eq!(def.memory, Some(MemoryScope::User));
1654 }
1655
1656 #[test]
1657 fn parse_yaml_memory_scope_local() {
1658 let content = "---\nname: reviewer\ndescription: A reviewer\nmemory: local\n---\n\nBody.\n";
1659 let def = SubAgentDef::parse(content).unwrap();
1660 assert_eq!(def.memory, Some(MemoryScope::Local));
1661 }
1662
1663 #[test]
1664 fn parse_yaml_memory_absent_gives_none() {
1665 let content = "---\nname: reviewer\ndescription: A reviewer\n---\n\nBody.\n";
1666 let def = SubAgentDef::parse(content).unwrap();
1667 assert!(def.memory.is_none());
1668 }
1669
1670 #[test]
1671 fn parse_yaml_memory_invalid_value_is_error() {
1672 let content =
1673 "---\nname: reviewer\ndescription: A reviewer\nmemory: global\n---\n\nBody.\n";
1674 let err = SubAgentDef::parse(content).unwrap_err();
1675 assert_matches!(err, SubAgentError::Parse { .. });
1676 }
1677
1678 #[test]
1679 fn memory_scope_serde_roundtrip() {
1680 for scope in [MemoryScope::User, MemoryScope::Project, MemoryScope::Local] {
1681 let json = serde_json::to_string(&scope).unwrap();
1682 let parsed: MemoryScope = serde_json::from_str(&json).unwrap();
1683 assert_eq!(parsed, scope);
1684 }
1685 }
1686
1687 #[test]
1690 fn parse_yaml_name_with_unicode_is_invalid() {
1691 let content = "---\nname: аgent\ndescription: b\n---\n\nbody\n";
1693 let err = SubAgentDef::parse(content).unwrap_err();
1694 assert_matches!(err, SubAgentError::Invalid(_));
1695 }
1696
1697 #[test]
1698 fn parse_yaml_name_with_space_is_invalid() {
1699 let content = "---\nname: my agent\ndescription: b\n---\n\nbody\n";
1700 let err = SubAgentDef::parse(content).unwrap_err();
1701 assert_matches!(err, SubAgentError::Invalid(_));
1702 }
1703
1704 #[test]
1705 fn parse_yaml_name_with_dot_is_invalid() {
1706 let content = "---\nname: my.agent\ndescription: b\n---\n\nbody\n";
1707 let err = SubAgentDef::parse(content).unwrap_err();
1708 assert_matches!(err, SubAgentError::Invalid(_));
1709 }
1710
1711 #[test]
1712 fn parse_yaml_name_single_char_is_valid() {
1713 let content = "---\nname: a\ndescription: b\n---\n\nbody\n";
1714 let def = SubAgentDef::parse(content).unwrap();
1715 assert_eq!(def.name, "a");
1716 }
1717
1718 #[test]
1719 fn parse_yaml_name_with_underscore_and_hyphen_is_valid() {
1720 let content = "---\nname: my_agent-v2\ndescription: b\n---\n\nbody\n";
1721 let def = SubAgentDef::parse(content).unwrap();
1722 assert_eq!(def.name, "my_agent-v2");
1723 }
1724
1725 #[test]
1728 fn default_template_valid() {
1729 let def = SubAgentDef::default_template("tester", "Runs tests");
1730 assert_eq!(def.name, "tester");
1731 assert_eq!(def.description, "Runs tests");
1732 assert!(def.model.is_none());
1733 assert_matches!(def.tools, ToolPolicy::InheritAll);
1734 assert!(def.system_prompt.is_empty());
1735 }
1736
1737 #[test]
1738 fn default_template_roundtrip() {
1739 let def = SubAgentDef::default_template("tester", "Runs tests");
1740 let markdown = def.serialize_to_markdown();
1741 let parsed = SubAgentDef::parse(&markdown).unwrap();
1742 assert_eq!(parsed.name, "tester");
1743 assert_eq!(parsed.description, "Runs tests");
1744 }
1745
1746 #[test]
1747 fn serialize_minimal() {
1748 let def = SubAgentDef::default_template("bot", "A bot");
1749 let md = def.serialize_to_markdown();
1750 assert!(md.starts_with("---\n"));
1751 assert!(md.contains("name: bot"));
1752 assert!(md.contains("description: A bot"));
1753 }
1754
1755 #[test]
1756 fn serialize_roundtrip() {
1757 let content = indoc! {"
1758 ---
1759 name: code-reviewer
1760 description: Reviews code changes for correctness and style
1761 model: claude-sonnet-4-20250514
1762 tools:
1763 allow:
1764 - shell
1765 - web_scrape
1766 permissions:
1767 max_turns: 10
1768 background: false
1769 timeout_secs: 300
1770 ttl_secs: 120
1771 skills:
1772 include:
1773 - \"git-*\"
1774 - \"rust-*\"
1775 exclude:
1776 - \"deploy-*\"
1777 ---
1778
1779 You are a code reviewer. Report findings with severity.
1780 "};
1781 let def = SubAgentDef::parse(content).unwrap();
1782 let serialized = def.serialize_to_markdown();
1783 let reparsed = SubAgentDef::parse(&serialized).unwrap();
1784 assert_eq!(reparsed.name, def.name);
1785 assert_eq!(reparsed.description, def.description);
1786 assert_eq!(reparsed.model, def.model);
1787 assert_eq!(reparsed.permissions.max_turns, def.permissions.max_turns);
1788 assert_eq!(
1789 reparsed.permissions.timeout_secs,
1790 def.permissions.timeout_secs
1791 );
1792 assert_eq!(reparsed.permissions.ttl_secs, def.permissions.ttl_secs);
1793 assert_eq!(reparsed.permissions.background, def.permissions.background);
1794 assert_eq!(
1795 reparsed.permissions.permission_mode,
1796 def.permissions.permission_mode
1797 );
1798 assert_eq!(reparsed.skills.include, def.skills.include);
1799 assert_eq!(reparsed.skills.exclude, def.skills.exclude);
1800 assert_eq!(reparsed.system_prompt, def.system_prompt);
1801 assert!(
1802 matches!(&reparsed.tools, ToolPolicy::AllowList(v) if v == &["shell", "web_scrape"])
1803 );
1804 }
1805
1806 #[test]
1807 fn serialize_roundtrip_tools_except() {
1808 let content = indoc! {"
1809 ---
1810 name: auditor
1811 description: Security auditor
1812 tools:
1813 allow:
1814 - shell
1815 except:
1816 - shell_sudo
1817 - shell_rm
1818 ---
1819
1820 Audit mode.
1821 "};
1822 let def = SubAgentDef::parse(content).unwrap();
1823 let serialized = def.serialize_to_markdown();
1824 let reparsed = SubAgentDef::parse(&serialized).unwrap();
1825 assert_eq!(reparsed.disallowed_tools, def.disallowed_tools);
1826 assert_eq!(reparsed.disallowed_tools, ["shell_sudo", "shell_rm"]);
1827 assert_matches!(&reparsed.tools, ToolPolicy::AllowList(v) if v == &["shell"]);
1828 }
1829
1830 #[test]
1831 fn serialize_all_fields() {
1832 let content = indoc! {"
1833 ---
1834 name: full-agent
1835 description: Full featured agent
1836 model: claude-opus-4-8
1837 tools:
1838 allow:
1839 - shell
1840 except:
1841 - shell_sudo
1842 permissions:
1843 max_turns: 5
1844 background: true
1845 timeout_secs: 120
1846 ttl_secs: 60
1847 skills:
1848 include:
1849 - \"git-*\"
1850 ---
1851
1852 System prompt here.
1853 "};
1854 let def = SubAgentDef::parse(content).unwrap();
1855 let md = def.serialize_to_markdown();
1856 assert!(md.contains("model: claude-opus-4-8"));
1857 assert!(md.contains("except:"));
1858 assert!(md.contains("shell_sudo"));
1859 assert!(md.contains("background: true"));
1860 assert!(md.contains("System prompt here."));
1861 }
1862
1863 #[test]
1864 fn save_atomic_creates_file() {
1865 let dir = tempfile::tempdir().unwrap();
1866 let def = SubAgentDef::default_template("myagent", "A test agent");
1867 let path = def.save_atomic(dir.path()).unwrap();
1868 assert!(path.exists());
1869 assert_eq!(path.file_name().unwrap(), "myagent.md");
1870 let content = std::fs::read_to_string(&path).unwrap();
1871 assert!(content.contains("name: myagent"));
1872 }
1873
1874 #[test]
1875 fn save_atomic_creates_parent_dirs() {
1876 let base = tempfile::tempdir().unwrap();
1877 let nested = base.path().join("a").join("b").join("c");
1878 let def = SubAgentDef::default_template("nested", "Nested dir test");
1879 let path = def.save_atomic(&nested).unwrap();
1880 assert!(path.exists());
1881 }
1882
1883 #[test]
1884 fn save_atomic_overwrites_existing() {
1885 let dir = tempfile::tempdir().unwrap();
1886 let def1 = SubAgentDef::default_template("agent", "First description");
1887 def1.save_atomic(dir.path()).unwrap();
1888
1889 let def2 = SubAgentDef::default_template("agent", "Second description");
1890 def2.save_atomic(dir.path()).unwrap();
1891
1892 let content = std::fs::read_to_string(dir.path().join("agent.md")).unwrap();
1893 assert!(content.contains("Second description"));
1894 assert!(!content.contains("First description"));
1895 }
1896
1897 #[test]
1898 fn delete_file_removes() {
1899 let dir = tempfile::tempdir().unwrap();
1900 let def = SubAgentDef::default_template("todelete", "Will be deleted");
1901 let path = def.save_atomic(dir.path()).unwrap();
1902 assert!(path.exists());
1903 SubAgentDef::delete_file(&path).unwrap();
1904 assert!(!path.exists());
1905 }
1906
1907 #[test]
1908 fn delete_file_nonexistent_errors() {
1909 let path = std::path::PathBuf::from("/tmp/does-not-exist-zeph-test.md");
1910 let result = SubAgentDef::delete_file(&path);
1911 assert!(result.is_err());
1912 assert_matches!(result.unwrap_err(), SubAgentError::Io { .. });
1913 }
1914
1915 #[test]
1916 fn save_atomic_rejects_invalid_name() {
1917 let dir = tempfile::tempdir().unwrap();
1918 let mut def = SubAgentDef::default_template("valid-name", "desc");
1919 def.name = "../../etc/cron.d/agent".to_owned();
1921 let result = def.save_atomic(dir.path());
1922 assert!(result.is_err());
1923 assert_matches!(result.unwrap_err(), SubAgentError::Invalid(_));
1924 }
1925
1926 #[test]
1927 fn is_valid_agent_name_accepts_valid() {
1928 assert!(super::is_valid_agent_name("reviewer"));
1929 assert!(super::is_valid_agent_name("code-reviewer"));
1930 assert!(super::is_valid_agent_name("code_reviewer"));
1931 assert!(super::is_valid_agent_name("a"));
1932 assert!(super::is_valid_agent_name("A1"));
1933 }
1934
1935 #[test]
1936 fn is_valid_agent_name_rejects_invalid() {
1937 assert!(!super::is_valid_agent_name(""));
1938 assert!(!super::is_valid_agent_name("my agent"));
1939 assert!(!super::is_valid_agent_name("../../etc"));
1940 assert!(!super::is_valid_agent_name("-starts-with-dash"));
1941 assert!(!super::is_valid_agent_name("has.dot"));
1942 }
1943}