Skip to main content

zeph_subagent/
def.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Sub-agent definition parsing and loading.
5//!
6//! A [`SubAgentDef`] is parsed from a Markdown file with YAML (or deprecated TOML)
7//! frontmatter. [`SubAgentDef::parse`] handles a content string directly;
8//! [`SubAgentDef::load`] reads from disk with optional symlink-boundary enforcement;
9//! [`SubAgentDef::load_all`] scans multiple priority-ordered directories.
10
11use 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
24/// Validated agent name pattern: ASCII alphanumeric, hyphen, underscore.
25/// Must start with alphanumeric, max 64 chars. Rejects unicode homoglyphs.
26pub(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
29/// Returns `true` if `name` is a valid sub-agent identifier.
30///
31/// Valid names match `^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`:
32/// - ASCII only (rejects unicode homoglyphs and full-width characters)
33/// - Must start with an alphanumeric character
34/// - Maximum 64 characters
35/// - Hyphens and underscores are allowed after the first character
36///
37/// # Examples
38///
39/// ```rust
40/// use zeph_subagent::is_valid_agent_name;
41///
42/// assert!(is_valid_agent_name("my-agent"));
43/// assert!(is_valid_agent_name("helper1"));
44/// assert!(!is_valid_agent_name("../etc")); // path traversal
45/// assert!(!is_valid_agent_name(""));       // empty
46/// assert!(!is_valid_agent_name("аgent"));  // cyrillic homoglyph
47/// ```
48pub fn is_valid_agent_name(name: &str) -> bool {
49    AGENT_NAME_RE.is_match(name)
50}
51
52/// Maximum allowed size for a sub-agent definition file (256 KiB).
53///
54/// Files larger than this are rejected before parsing to cap memory usage.
55const MAX_DEF_SIZE: usize = 256 * 1024;
56
57/// Maximum number of `.md` files scanned per directory.
58///
59/// Prevents accidental denial-of-service when `--agents /home` or similar large flat
60/// directories are passed. A warning is emitted when the cap is hit.
61const MAX_ENTRIES_PER_DIR: usize = 100;
62
63// ── Public types ──────────────────────────────────────────────────────────────
64
65/// Parsed and validated sub-agent definition loaded from a `.md` file.
66///
67/// A `SubAgentDef` is the runtime representation of a sub-agent's configuration.
68/// Definitions are loaded from Markdown files with YAML (or deprecated TOML) frontmatter
69/// and a system prompt body.
70///
71/// # File format
72///
73/// ```text
74/// ---
75/// name: code-reviewer
76/// description: Reviews pull requests for correctness and style
77/// model: claude-sonnet-4
78/// tools:
79///   allow:
80///     - shell
81///     - Read
82/// permissions:
83///   max_turns: 15
84///   timeout_secs: 300
85/// skills:
86///   include:
87///     - "git-*"
88/// ---
89///
90/// You are an expert code reviewer. Focus on correctness, style, and security.
91/// ```
92///
93/// # Errors
94///
95/// [`SubAgentDef::parse`] returns [`SubAgentError::Parse`] if the frontmatter is malformed
96/// and [`SubAgentError::Invalid`] if semantic constraints are violated.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct SubAgentDef {
99    /// Unique identifier for this agent (ASCII alphanumeric + hyphen/underscore, max 64 chars).
100    pub name: String,
101    /// Human-readable description shown in `/agent list` output.
102    pub description: String,
103    /// Override the default LLM model for this agent. `None` inherits the parent's provider.
104    pub model: Option<ModelSpec>,
105    /// Base tool access policy derived from `tools.allow` or `tools.deny` in frontmatter.
106    pub tools: ToolPolicy,
107    /// Additional denylist applied after the base `tools` policy.
108    ///
109    /// Populated from `tools.except` in YAML frontmatter. Deny wins: tools listed
110    /// here are blocked even when they appear in `tools.allow`.
111    ///
112    /// # Serde asymmetry (IMP-CRIT-04)
113    ///
114    /// Deserialization reads this field from the nested `tools.except` key in YAML/TOML
115    /// frontmatter. Serialization (via `#[derive(Serialize)]`) writes it as a flat
116    /// top-level `disallowed_tools` key — not under `tools`. Round-trip serialization
117    /// is therefore not supported: a serialized `SubAgentDef` cannot be parsed back
118    /// as a valid frontmatter file. This is intentional for the current MVP but must
119    /// be addressed before v1.0.0 (see GitHub issue filed under IMP-CRIT-04).
120    pub disallowed_tools: Vec<String>,
121    /// Runtime permission settings: secrets, turn limits, background mode, timeouts.
122    pub permissions: SubAgentPermissions,
123    /// Glob patterns controlling which skills are visible to this agent.
124    pub skills: SkillFilter,
125    /// The markdown body of the definition file, used as the agent's system prompt.
126    pub system_prompt: String,
127    /// Per-agent hooks (`PreToolUse` / `PostToolUse`) from frontmatter.
128    ///
129    /// Hooks are only honored for project-level and CLI-level definitions.
130    /// User-level definitions (~/.zeph/agents/) have hooks stripped on load.
131    pub hooks: SubagentHooks,
132    /// Persistent memory scope. When set, a memory directory is created at spawn time
133    /// and `MEMORY.md` content is injected into the system prompt.
134    pub memory: Option<MemoryScope>,
135    /// Scope label and filename of the definition file (populated by `load` / `load_all`).
136    ///
137    /// Stored as `"<scope>/<filename>"` (e.g., `"project/my-agent.md"`).
138    /// The full absolute path is intentionally not stored to avoid leaking local
139    /// filesystem layout in diagnostics and `/agent list` output.
140    #[serde(skip)]
141    pub source: Option<String>,
142    /// Full filesystem path of the definition file (populated by `load_with_boundary`).
143    ///
144    /// Used internally by edit/delete operations. Not included in diagnostics output.
145    #[serde(skip)]
146    pub file_path: Option<PathBuf>,
147}
148
149impl SubAgentDef {
150    /// Construct a minimal `SubAgentDef` for use in unit tests across crates.
151    ///
152    /// Produces an agent with `InheritAll` tools, default permissions, and empty
153    /// prompt/skills/hooks. Tests that need a specific `tools`, `model`, or
154    /// `disallowed_tools` mutate the returned value.
155    #[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/// Runtime permission settings for a sub-agent.
175///
176/// All fields have defaults that apply when the `permissions` section is absent from
177/// the frontmatter: 20 turns, 600 s timeout, foreground execution, default permission mode.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct SubAgentPermissions {
180    /// Vault secret keys this agent is allowed to request at runtime.
181    pub secrets: Vec<String>,
182    /// Maximum number of LLM turns before the agent is force-stopped.
183    pub max_turns: u32,
184    /// When `true`, the agent runs independently of the parent cancellation token.
185    pub background: bool,
186    /// Hard wall-clock timeout in seconds for the entire agent session.
187    pub timeout_secs: u64,
188    /// Time-to-live in seconds for permission grants issued to this agent.
189    pub ttl_secs: u64,
190    /// Controls tool access philosophy (`Default`, `Plan`, `BypassPermissions`).
191    pub permission_mode: PermissionMode,
192    /// Maximum number of messages retained in the in-memory history buffer.
193    ///
194    /// When the live `messages` vec exceeds this limit the oldest non-system messages
195    /// are evicted from the front, keeping the system message intact. Set to `0` to
196    /// disable eviction entirely (not recommended for long-running agents).
197    pub max_history_messages: usize,
198    /// When `true`, the agent runs inside a dedicated git worktree (INV-1/INV-3).
199    ///
200    /// Requires `worktree.enabled = true` in the global config and a non-`None`
201    /// `bg_isolation` setting.  When the worktree subsystem is disabled, this field
202    /// is silently ignored.
203    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// ── Raw deserialization structs ───────────────────────────────────────────────
222// These work for both YAML and TOML deserializers — only the deserializer call
223// differs based on detected frontmatter format.
224
225#[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// Note: `RawToolPolicy` and `RawPermissions` are nested under `RawSubAgentDef` (which also
244// carries `deny_unknown_fields`), but serde does not propagate that attribute into nested
245// structs — each struct must declare it independently. Both structs already mark every field
246// `#[serde(default = ...)]`, so `deny_unknown_fields` only rejects genuinely unknown keys
247// (e.g. typos) and does not affect omission of optional fields.
248#[derive(Default, Deserialize)]
249#[serde(deny_unknown_fields)]
250struct RawToolPolicy {
251    allow: Option<Vec<String>>,
252    deny: Option<Vec<String>>,
253    /// Additional denylist applied on top of `allow` or `deny`. Use `tools.except` to
254    /// block specific tools while still using an allow-list (deny wins over allow).
255    #[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// ── Frontmatter format detection ──────────────────────────────────────────────
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
319enum FrontmatterFormat {
320    Yaml,
321    Toml,
322}
323
324/// Split frontmatter from markdown body, detecting format from opening delimiter.
325///
326/// YAML frontmatter (primary):
327/// ```text
328/// ---
329/// <yaml content>
330/// ---
331///
332/// <body>
333/// ```
334///
335/// TOML frontmatter (deprecated):
336/// ```text
337/// +++
338/// <toml content>
339/// +++
340///
341/// <body>
342/// ```
343fn 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        // YAML: closing delimiter is \n---\n or \n--- at EOF.
357        // Note: `split_once("\n---")` matches `\r\n---` because `\r\n` contains `\n`.
358        // The leading `\r` is left in `yaml_str` but removed by CRLF normalization in
359        // `parse_with_path`. Do not remove that normalization without updating this search.
360        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        // Same CRLF note as YAML branch above: trailing `\r` is cleaned by normalization.
375        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    /// Parse a sub-agent definition from its frontmatter+markdown content.
392    ///
393    /// The primary format uses YAML frontmatter delimited by `---`:
394    ///
395    /// ```text
396    /// ---
397    /// name: my-agent
398    /// description: Does something useful
399    /// model: claude-sonnet-4-20250514
400    /// tools:
401    ///   allow:
402    ///     - shell
403    /// permissions:
404    ///   max_turns: 10
405    /// skills:
406    ///   include:
407    ///     - "git-*"
408    /// ---
409    ///
410    /// You are a helpful agent.
411    /// ```
412    ///
413    /// TOML frontmatter (`+++`) is supported as a deprecated fallback and will emit a
414    /// `tracing::warn!` message. It will be removed in v1.0.0.
415    ///
416    /// # Errors
417    ///
418    /// Returns [`SubAgentError::Parse`] if the frontmatter delimiters are missing or the
419    /// content is malformed, and [`SubAgentError::Invalid`] if required fields are empty or
420    /// `tools.allow` and `tools.deny` are both specified.
421    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                // Normalize CRLF so numeric/bool fields parse correctly on Windows line endings.
432                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                // Normalize CRLF — the `toml` crate rejects bare `\r`.
450                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        // CRIT-01: unified name validation — ASCII-only, path-safe, max 64 chars.
473        // Rejects unicode homoglyphs, full-width chars, path separators, and control chars.
474        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    /// Load a single definition from a `.md` file.
540    ///
541    /// When `boundary` is provided, the file's canonical path must start with
542    /// `boundary` — this rejects symlinks that escape the allowed directory.
543    ///
544    /// # Errors
545    ///
546    /// Returns [`SubAgentError::Parse`] if the file cannot be read, exceeds 256 KiB,
547    /// escapes the boundary via symlink, or fails to parse.
548    pub fn load(path: &Path) -> Result<Self, SubAgentError> {
549        Self::load_with_boundary(path, None, None)
550    }
551
552    /// Load with optional symlink boundary and scope label for the `source` field.
553    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        // Canonicalize to resolve any symlinks before reading.
561        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        // Boundary check: reject symlinks that escape the allowed directory.
567        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        // Security: strip hooks from user-level definitions — only project-level
595        // (scope = "project") and CLI-level (scope = "cli" or None) definitions may
596        // carry hooks. User-level agents come from ~/.zeph/agents/ and are untrusted.
597        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        // Populate source as "<scope>/<filename>" — no full path to avoid privacy leak.
608        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        // Populate file_path for edit/delete operations (not used in diagnostics output).
618        def.file_path = Some(canonical);
619
620        Ok(def)
621    }
622
623    /// Load all definitions from a list of paths (files or directories).
624    ///
625    /// Paths are processed in order; when two entries share the same agent
626    /// `name`, the first one wins (higher-priority path takes precedence).
627    /// Non-existent directories are silently skipped.
628    ///
629    /// For directory entries from user/extra dirs: parse errors are warned and skipped.
630    /// For CLI file entries (`is_cli_source = true`): parse errors are hard failures.
631    ///
632    /// # Errors
633    ///
634    /// Returns [`SubAgentError`] if a CLI-sourced `.md` file fails to parse.
635    pub fn load_all(paths: &[PathBuf]) -> Result<Vec<Self>, SubAgentError> {
636        Self::load_all_with_sources(paths, &[], None, &[])
637    }
638
639    /// Load all definitions with scope context for source tracking and security checks.
640    ///
641    /// `cli_agents` — CLI paths (hard errors on parse failure, no boundary check).
642    /// `config_user_dir` — optional user-level dir override.
643    /// `extra_dirs` — extra dirs from config.
644    ///
645    /// # Errors
646    ///
647    /// Returns [`SubAgentError`] if a CLI-sourced `.md` file fails to parse.
648    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                // Single file path: only CLI --agents flag produces file entries in ordered_paths
660                // (project/user/extra_dirs are always directories). Scope label "cli" is
661                // therefore always correct here.
662                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; // directory doesn't exist — skip silently
686            };
687
688            // Compute boundary for symlink protection. CLI dirs are trusted (user-supplied,
689            // already validated by the shell). All other dirs (project, user, extra) get a
690            // canonical boundary check to reject symlinks that escape the allowed directory.
691            let is_cli_dir = cli_agents.iter().any(|c| c == path);
692            let boundary = if is_cli_dir {
693                None
694            } else {
695                // Canonicalize the directory itself as the boundary.
696                // This applies to project dir (.zeph/agents) as well — a symlink at
697                // .zeph/agents pointing outside the project would be rejected.
698                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(); // deterministic order within a directory
711
712            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// ── Serialization helpers ────────────────────────────────────────────────────
757
758/// Mirror of `RawSubAgentDef` with correct `tools.except` nesting for round-trip
759/// serialization. Avoids the IMP-CRIT-04 serde asymmetry on `SubAgentDef`.
760#[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    /// Serialize the definition to YAML frontmatter + markdown body.
852    ///
853    /// Uses `WritableRawDef` (with correct `tools.except` nesting) to avoid the
854    /// IMP-CRIT-04 serde asymmetry. The result can be re-parsed with `SubAgentDef::parse`.
855    ///
856    /// # Panics
857    ///
858    /// Panics if `serde_norway` serialization fails (should not happen for valid structs).
859    #[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    /// Write definition to `{dir}/{self.name}.md` atomically using temp+rename.
884    ///
885    /// Creates parent directories if needed. Uses `tempfile::NamedTempFile` in the same
886    /// directory for automatic cleanup on failure.
887    ///
888    /// # Errors
889    ///
890    /// Returns [`SubAgentError::Invalid`] if the agent name fails validation (prevents path traversal).
891    /// Returns [`SubAgentError::Io`] if directory creation, write, or rename fails.
892    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    /// Delete a definition file from disk.
926    ///
927    /// # Errors
928    ///
929    /// Returns [`SubAgentError::Io`] if the file does not exist or cannot be removed.
930    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    /// Create a minimal definition suitable for the create wizard.
938    ///
939    /// Sets sensible defaults: `InheritAll` tools, default permissions, empty system prompt.
940    #[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// ── Tests ─────────────────────────────────────────────────────────────────────
960
961#[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    // ── YAML fixtures (primary format) ─────────────────────────────────────────
971
972    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    // ── TOML fixtures (deprecated fallback) ────────────────────────────────────
1009
1010    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    // ── YAML tests ─────────────────────────────────────────────────────────────
1044
1045    #[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        // --- in the body after the closing --- delimiter must not break the parser
1082        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        // Explicit tools section with neither allow nor deny also yields InheritAll.
1099        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    // ── TOML deprecated fallback tests ─────────────────────────────────────────
1170
1171    #[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        // Non-CLI source: bad file is warned and skipped, good file is loaded.
1337        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        // CLI source: bad file causes hard error.
1353        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        // Create MAX_ENTRIES_PER_DIR + 10 files; only first 100 should be loaded.
1366        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        // Create two separate dirs. Place a real file in dir_b, then create a symlink in
1384        // dir_a pointing to the file in dir_b. Loading with dir_a as boundary must fail.
1385        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            // Symlink boundary test is unix-specific; skip on other platforms.
1398            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        // Create a dir that will be treated as the user-level dir.
1420        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        // Use user_dir as config_user_dir so scope_label returns "user".
1427        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        // Both dirs contain an agent with the same name "bot".
1446        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        // dir1 is first (higher priority), dir2 is second.
1455        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        // When config_user_dir is not provided to load_all_with_sources (None),
1465        // and the resolved ordered_paths has no user dir entry, loading must succeed.
1466        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        // Pass only project-level-like path — no user dir at all.
1471        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    // ── PermissionMode tests ────────────────────────────────────────────────
1478
1479    #[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    // ── #1185: additional test gaps ────────────────────────────────────────
1567
1568    #[test]
1569    fn parse_yaml_unknown_permission_mode_variant_is_error() {
1570        // Unknown variant (e.g. "banana_mode") must fail with a parse error.
1571        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        // "DontAsk" (camelCase) must not parse — only snake_case is accepted.
1579        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        // disallowed_tools (tools.except) blocks a tool even when DenyList base policy
1595        // would otherwise allow it (deny wins).
1596        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        // base policy: DenyList blocks "dangerous", allows everything else
1599        assert_matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["dangerous"]);
1600        // disallowed_tools: "web" is additionally blocked by except
1601        assert!(def.disallowed_tools.contains(&"web".to_owned()));
1602    }
1603
1604    #[test]
1605    fn parse_toml_background_true_frontmatter() {
1606        // background: true via TOML (+++) frontmatter must parse correctly.
1607        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        // deny_unknown_fields on RawSubAgentDef: typos like "permisions:" must be rejected.
1616        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        // deny_unknown_fields on RawPermissions (#6583): a typo like "pemission_mode:" inside
1624        // the nested `permissions:` section must be rejected, not silently ignored.
1625        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        // deny_unknown_fields on RawToolPolicy (#6583): a typo like "alow:" inside the nested
1633        // `tools:` section must be rejected, not silently ignored.
1634        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    // ── MemoryScope / memory field tests ────────────────────────────────────
1640
1641    #[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    // ── Agent name validation tests (CRIT-01) ────────────────────────────────
1688
1689    #[test]
1690    fn parse_yaml_name_with_unicode_is_invalid() {
1691        // Cyrillic 'а' (U+0430) looks like Latin 'a' but is rejected.
1692        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    // ── Serialization / save / delete / template tests ────────────────────────
1726
1727    #[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        // Bypass default_template to inject an invalid name.
1920        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}