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