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` intentionally do not carry
244// `#[serde(deny_unknown_fields)]`. They are nested under `RawSubAgentDef` (which does have
245// `deny_unknown_fields`), but serde does not propagate that attribute into nested structs.
246// Adding it here would reject currently-valid frontmatter that omits optional fields via
247// serde's default mechanism. A follow-up issue should evaluate whether strict rejection of
248// unknown nested keys is desirable before adding it.
249#[derive(Default, Deserialize)]
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)]
260struct RawPermissions {
261    #[serde(default)]
262    secrets: Vec<String>,
263    #[serde(default = "default_max_turns")]
264    max_turns: u32,
265    #[serde(default)]
266    background: bool,
267    #[serde(default = "default_timeout")]
268    timeout_secs: u64,
269    #[serde(default = "default_ttl")]
270    ttl_secs: u64,
271    #[serde(default)]
272    permission_mode: PermissionMode,
273    #[serde(default = "default_max_history_messages")]
274    max_history_messages: usize,
275    #[serde(default)]
276    worktree: bool,
277}
278
279impl Default for RawPermissions {
280    fn default() -> Self {
281        Self {
282            secrets: Vec::new(),
283            max_turns: default_max_turns(),
284            background: false,
285            timeout_secs: default_timeout(),
286            ttl_secs: default_ttl(),
287            permission_mode: PermissionMode::Default,
288            max_history_messages: default_max_history_messages(),
289            worktree: false,
290        }
291    }
292}
293
294#[derive(Default, Deserialize)]
295struct RawSkillFilter {
296    #[serde(default)]
297    include: Vec<String>,
298    #[serde(default)]
299    exclude: Vec<String>,
300}
301
302fn default_max_turns() -> u32 {
303    20
304}
305fn default_timeout() -> u64 {
306    600
307}
308fn default_ttl() -> u64 {
309    300
310}
311fn default_max_history_messages() -> usize {
312    200
313}
314
315// ── Frontmatter format detection ──────────────────────────────────────────────
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq)]
318enum FrontmatterFormat {
319    Yaml,
320    Toml,
321}
322
323/// Split frontmatter from markdown body, detecting format from opening delimiter.
324///
325/// YAML frontmatter (primary):
326/// ```text
327/// ---
328/// <yaml content>
329/// ---
330///
331/// <body>
332/// ```
333///
334/// TOML frontmatter (deprecated):
335/// ```text
336/// +++
337/// <toml content>
338/// +++
339///
340/// <body>
341/// ```
342fn split_frontmatter<'a>(
343    content: &'a str,
344    path: &str,
345) -> Result<(&'a str, &'a str, FrontmatterFormat), SubAgentError> {
346    let make_err = |reason: &str| SubAgentError::Parse {
347        path: path.to_owned(),
348        reason: reason.to_owned(),
349    };
350
351    if let Some(rest) = content
352        .strip_prefix("---")
353        .and_then(|s| s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n")))
354    {
355        // YAML: closing delimiter is \n---\n or \n--- at EOF.
356        // Note: `split_once("\n---")` matches `\r\n---` because `\r\n` contains `\n`.
357        // The leading `\r` is left in `yaml_str` but removed by CRLF normalization in
358        // `parse_with_path`. Do not remove that normalization without updating this search.
359        let (yaml_str, after) = rest
360            .split_once("\n---")
361            .ok_or_else(|| make_err("missing closing `---` delimiter for YAML frontmatter"))?;
362        let body = after
363            .strip_prefix('\n')
364            .or_else(|| after.strip_prefix("\r\n"))
365            .unwrap_or(after);
366        return Ok((yaml_str, body, FrontmatterFormat::Yaml));
367    }
368
369    if let Some(rest) = content
370        .strip_prefix("+++")
371        .and_then(|s| s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n")))
372    {
373        // Same CRLF note as YAML branch above: trailing `\r` is cleaned by normalization.
374        let (toml_str, after) = rest
375            .split_once("\n+++")
376            .ok_or_else(|| make_err("missing closing `+++` delimiter for TOML frontmatter"))?;
377        let body = after
378            .strip_prefix('\n')
379            .or_else(|| after.strip_prefix("\r\n"))
380            .unwrap_or(after);
381        return Ok((toml_str, body, FrontmatterFormat::Toml));
382    }
383
384    Err(make_err(
385        "missing frontmatter delimiters: expected `---` (YAML) or `+++` (TOML, deprecated)",
386    ))
387}
388
389impl SubAgentDef {
390    /// Parse a sub-agent definition from its frontmatter+markdown content.
391    ///
392    /// The primary format uses YAML frontmatter delimited by `---`:
393    ///
394    /// ```text
395    /// ---
396    /// name: my-agent
397    /// description: Does something useful
398    /// model: claude-sonnet-4-20250514
399    /// tools:
400    ///   allow:
401    ///     - shell
402    /// permissions:
403    ///   max_turns: 10
404    /// skills:
405    ///   include:
406    ///     - "git-*"
407    /// ---
408    ///
409    /// You are a helpful agent.
410    /// ```
411    ///
412    /// TOML frontmatter (`+++`) is supported as a deprecated fallback and will emit a
413    /// `tracing::warn!` message. It will be removed in v1.0.0.
414    ///
415    /// # Errors
416    ///
417    /// Returns [`SubAgentError::Parse`] if the frontmatter delimiters are missing or the
418    /// content is malformed, and [`SubAgentError::Invalid`] if required fields are empty or
419    /// `tools.allow` and `tools.deny` are both specified.
420    pub fn parse(content: &str) -> Result<Self, SubAgentError> {
421        Self::parse_with_path(content, "<unknown>")
422    }
423
424    #[allow(clippy::too_many_lines)]
425    fn parse_with_path(content: &str, path: &str) -> Result<Self, SubAgentError> {
426        let (frontmatter_str, body, format) = split_frontmatter(content, path)?;
427
428        let raw: RawSubAgentDef = match format {
429            FrontmatterFormat::Yaml => {
430                // Normalize CRLF so numeric/bool fields parse correctly on Windows line endings.
431                let yaml_normalized;
432                let yaml_str = if frontmatter_str.contains('\r') {
433                    yaml_normalized = frontmatter_str.replace("\r\n", "\n").replace('\r', "\n");
434                    &yaml_normalized
435                } else {
436                    frontmatter_str
437                };
438                serde_norway::from_str(yaml_str).map_err(|e| SubAgentError::Parse {
439                    path: path.to_owned(),
440                    reason: e.to_string(),
441                })?
442            }
443            FrontmatterFormat::Toml => {
444                tracing::warn!(
445                    path,
446                    "sub-agent definition uses deprecated +++ TOML frontmatter, migrate to --- YAML"
447                );
448                // Normalize CRLF — the `toml` crate rejects bare `\r`.
449                let toml_normalized;
450                let toml_str = if frontmatter_str.contains('\r') {
451                    toml_normalized = frontmatter_str.replace("\r\n", "\n").replace('\r', "\n");
452                    &toml_normalized
453                } else {
454                    frontmatter_str
455                };
456                toml::from_str(toml_str).map_err(|e| SubAgentError::Parse {
457                    path: path.to_owned(),
458                    reason: e.to_string(),
459                })?
460            }
461        };
462
463        if raw.name.trim().is_empty() {
464            return Err(SubAgentError::Invalid("name must not be empty".into()));
465        }
466        if raw.description.trim().is_empty() {
467            return Err(SubAgentError::Invalid(
468                "description must not be empty".into(),
469            ));
470        }
471        // CRIT-01: unified name validation — ASCII-only, path-safe, max 64 chars.
472        // Rejects unicode homoglyphs, full-width chars, path separators, and control chars.
473        if !AGENT_NAME_RE.is_match(&raw.name) {
474            return Err(SubAgentError::Invalid(format!(
475                "name '{}' is invalid: must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{{0,63}}$ \
476                 (ASCII only, no spaces or special characters)",
477                raw.name
478            )));
479        }
480        if raw
481            .description
482            .chars()
483            .any(|c| (c < '\x20' && c != '\t') || c == '\x7F')
484        {
485            return Err(SubAgentError::Invalid(
486                "description must not contain control characters".into(),
487            ));
488        }
489
490        let tools = match (raw.tools.allow, raw.tools.deny) {
491            (None, None) => ToolPolicy::InheritAll,
492            (Some(list), None) => ToolPolicy::AllowList(list),
493            (None, Some(list)) => ToolPolicy::DenyList(list),
494            (Some(_), Some(_)) => {
495                return Err(SubAgentError::Invalid(
496                    "tools.allow and tools.deny are mutually exclusive".into(),
497                ));
498            }
499        };
500
501        let disallowed_tools = raw.tools.except;
502
503        let p = raw.permissions;
504        if p.permission_mode == PermissionMode::BypassPermissions {
505            tracing::warn!(
506                name = %raw.name,
507                "sub-agent definition uses bypass_permissions mode — grants unrestricted tool access"
508            );
509        }
510        Ok(Self {
511            name: raw.name,
512            description: raw.description,
513            model: raw.model,
514            tools,
515            disallowed_tools,
516            permissions: SubAgentPermissions {
517                secrets: p.secrets,
518                max_turns: p.max_turns,
519                background: p.background,
520                timeout_secs: p.timeout_secs,
521                ttl_secs: p.ttl_secs,
522                permission_mode: p.permission_mode,
523                max_history_messages: p.max_history_messages,
524                worktree: p.worktree,
525            },
526            skills: SkillFilter {
527                include: raw.skills.include,
528                exclude: raw.skills.exclude,
529            },
530            hooks: raw.hooks,
531            memory: raw.memory,
532            system_prompt: body.trim().to_owned(),
533            source: None,
534            file_path: None,
535        })
536    }
537
538    /// Load a single definition from a `.md` file.
539    ///
540    /// When `boundary` is provided, the file's canonical path must start with
541    /// `boundary` — this rejects symlinks that escape the allowed directory.
542    ///
543    /// # Errors
544    ///
545    /// Returns [`SubAgentError::Parse`] if the file cannot be read, exceeds 256 KiB,
546    /// escapes the boundary via symlink, or fails to parse.
547    pub fn load(path: &Path) -> Result<Self, SubAgentError> {
548        Self::load_with_boundary(path, None, None)
549    }
550
551    /// Load with optional symlink boundary and scope label for the `source` field.
552    pub(crate) fn load_with_boundary(
553        path: &Path,
554        boundary: Option<&Path>,
555        scope: Option<&str>,
556    ) -> Result<Self, SubAgentError> {
557        let path_str = path.display().to_string();
558
559        // Canonicalize to resolve any symlinks before reading.
560        let canonical = std::fs::canonicalize(path).map_err(|e| SubAgentError::Parse {
561            path: path_str.clone(),
562            reason: format!("cannot resolve path: {e}"),
563        })?;
564
565        // Boundary check: reject symlinks that escape the allowed directory.
566        if let Some(boundary) = boundary
567            && !canonical.starts_with(boundary)
568        {
569            return Err(SubAgentError::Parse {
570                path: path_str.clone(),
571                reason: format!(
572                    "definition file escapes allowed directory boundary ({})",
573                    boundary.display()
574                ),
575            });
576        }
577
578        let content = std::fs::read_to_string(&canonical).map_err(|e| SubAgentError::Parse {
579            path: path_str.clone(),
580            reason: e.to_string(),
581        })?;
582        if content.len() > MAX_DEF_SIZE {
583            return Err(SubAgentError::Parse {
584                path: path_str.clone(),
585                reason: format!(
586                    "definition file exceeds maximum size of {} KiB",
587                    MAX_DEF_SIZE / 1024
588                ),
589            });
590        }
591        let mut def = Self::parse_with_path(&content, &path_str)?;
592
593        // Security: strip hooks from user-level definitions — only project-level
594        // (scope = "project") and CLI-level (scope = "cli" or None) definitions may
595        // carry hooks. User-level agents come from ~/.zeph/agents/ and are untrusted.
596        if scope == Some("user") {
597            if !def.hooks.pre_tool_use.is_empty() || !def.hooks.post_tool_use.is_empty() {
598                tracing::warn!(
599                    path = %path_str,
600                    "user-level agent definition contains hooks — stripping for security"
601                );
602            }
603            def.hooks = SubagentHooks::default();
604        }
605
606        // Populate source as "<scope>/<filename>" — no full path to avoid privacy leak.
607        let filename = path
608            .file_name()
609            .and_then(|f| f.to_str())
610            .unwrap_or("<unknown>");
611        def.source = Some(if let Some(scope) = scope {
612            format!("{scope}/{filename}")
613        } else {
614            filename.to_owned()
615        });
616        // Populate file_path for edit/delete operations (not used in diagnostics output).
617        def.file_path = Some(canonical);
618
619        Ok(def)
620    }
621
622    /// Load all definitions from a list of paths (files or directories).
623    ///
624    /// Paths are processed in order; when two entries share the same agent
625    /// `name`, the first one wins (higher-priority path takes precedence).
626    /// Non-existent directories are silently skipped.
627    ///
628    /// For directory entries from user/extra dirs: parse errors are warned and skipped.
629    /// For CLI file entries (`is_cli_source = true`): parse errors are hard failures.
630    ///
631    /// # Errors
632    ///
633    /// Returns [`SubAgentError`] if a CLI-sourced `.md` file fails to parse.
634    pub fn load_all(paths: &[PathBuf]) -> Result<Vec<Self>, SubAgentError> {
635        Self::load_all_with_sources(paths, &[], None, &[])
636    }
637
638    /// Load all definitions with scope context for source tracking and security checks.
639    ///
640    /// `cli_agents` — CLI paths (hard errors on parse failure, no boundary check).
641    /// `config_user_dir` — optional user-level dir override.
642    /// `extra_dirs` — extra dirs from config.
643    ///
644    /// # Errors
645    ///
646    /// Returns [`SubAgentError`] if a CLI-sourced `.md` file fails to parse.
647    pub fn load_all_with_sources(
648        ordered_paths: &[PathBuf],
649        cli_agents: &[PathBuf],
650        config_user_dir: Option<&PathBuf>,
651        extra_dirs: &[PathBuf],
652    ) -> Result<Vec<Self>, SubAgentError> {
653        let mut seen: HashSet<String> = HashSet::new();
654        let mut result = Vec::new();
655
656        for path in ordered_paths {
657            if path.is_file() {
658                // Single file path: only CLI --agents flag produces file entries in ordered_paths
659                // (project/user/extra_dirs are always directories). Scope label "cli" is
660                // therefore always correct here.
661                let is_cli = cli_agents.iter().any(|c| c == path);
662                match Self::load_with_boundary(path, None, Some("cli")) {
663                    Ok(def) => {
664                        if seen.contains(&def.name) {
665                            tracing::debug!(
666                                name = %def.name,
667                                path = %path.display(),
668                                "skipping duplicate sub-agent definition"
669                            );
670                        } else {
671                            seen.insert(def.name.clone());
672                            result.push(def);
673                        }
674                    }
675                    Err(e) if is_cli => return Err(e),
676                    Err(e) => {
677                        tracing::warn!(path = %path.display(), error = %e, "skipping malformed agent definition");
678                    }
679                }
680                continue;
681            }
682
683            let Ok(read_dir) = std::fs::read_dir(path) else {
684                continue; // directory doesn't exist — skip silently
685            };
686
687            // Compute boundary for symlink protection. CLI dirs are trusted (user-supplied,
688            // already validated by the shell). All other dirs (project, user, extra) get a
689            // canonical boundary check to reject symlinks that escape the allowed directory.
690            let is_cli_dir = cli_agents.iter().any(|c| c == path);
691            let boundary = if is_cli_dir {
692                None
693            } else {
694                // Canonicalize the directory itself as the boundary.
695                // This applies to project dir (.zeph/agents) as well — a symlink at
696                // .zeph/agents pointing outside the project would be rejected.
697                std::fs::canonicalize(path).ok()
698            };
699
700            let scope = super::resolve::scope_label(path, cli_agents, config_user_dir, extra_dirs);
701            let is_cli_scope = is_cli_dir;
702
703            let mut entries: Vec<PathBuf> = read_dir
704                .filter_map(std::result::Result::ok)
705                .map(|e| e.path())
706                .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
707                .collect();
708
709            entries.sort(); // deterministic order within a directory
710
711            if entries.len() > MAX_ENTRIES_PER_DIR {
712                tracing::warn!(
713                    dir = %path.display(),
714                    count = entries.len(),
715                    cap = MAX_ENTRIES_PER_DIR,
716                    "agent directory exceeds entry cap; processing only first {MAX_ENTRIES_PER_DIR} files"
717                );
718                entries.truncate(MAX_ENTRIES_PER_DIR);
719            }
720
721            for entry_path in entries {
722                let load_result =
723                    Self::load_with_boundary(&entry_path, boundary.as_deref(), Some(scope));
724
725                let def = match load_result {
726                    Ok(d) => d,
727                    Err(e) if is_cli_scope => return Err(e),
728                    Err(e) => {
729                        tracing::warn!(
730                            path = %entry_path.display(),
731                            error = %e,
732                            "skipping malformed agent definition"
733                        );
734                        continue;
735                    }
736                };
737
738                if seen.contains(&def.name) {
739                    tracing::debug!(
740                        name = %def.name,
741                        path = %entry_path.display(),
742                        "skipping duplicate sub-agent definition (shadowed by higher-priority path)"
743                    );
744                    continue;
745                }
746                seen.insert(def.name.clone());
747                result.push(def);
748            }
749        }
750
751        Ok(result)
752    }
753}
754
755// ── Serialization helpers ────────────────────────────────────────────────────
756
757/// Mirror of `RawSubAgentDef` with correct `tools.except` nesting for round-trip
758/// serialization. Avoids the IMP-CRIT-04 serde asymmetry on `SubAgentDef`.
759#[derive(Serialize)]
760struct WritableRawDef<'a> {
761    name: &'a str,
762    description: &'a str,
763    #[serde(skip_serializing_if = "Option::is_none")]
764    model: Option<&'a ModelSpec>,
765    #[serde(skip_serializing_if = "WritableToolPolicy::is_inherit_all")]
766    tools: WritableToolPolicy<'a>,
767    #[serde(skip_serializing_if = "WritablePermissions::is_default")]
768    permissions: WritablePermissions<'a>,
769    #[serde(skip_serializing_if = "SkillFilter::is_empty")]
770    skills: &'a SkillFilter,
771    #[serde(skip_serializing_if = "SubagentHooks::is_empty")]
772    hooks: &'a SubagentHooks,
773    #[serde(skip_serializing_if = "Option::is_none")]
774    memory: Option<MemoryScope>,
775}
776
777#[derive(Serialize)]
778struct WritableToolPolicy<'a> {
779    #[serde(skip_serializing_if = "Option::is_none")]
780    allow: Option<&'a Vec<String>>,
781    #[serde(skip_serializing_if = "Option::is_none")]
782    deny: Option<&'a Vec<String>>,
783    #[serde(skip_serializing_if = "Vec::is_empty")]
784    except: &'a Vec<String>,
785}
786
787impl<'a> WritableToolPolicy<'a> {
788    fn from_def(policy: &'a ToolPolicy, except: &'a Vec<String>) -> Self {
789        match policy {
790            ToolPolicy::AllowList(v) => Self {
791                allow: Some(v),
792                deny: None,
793                except,
794            },
795            ToolPolicy::DenyList(v) => Self {
796                allow: None,
797                deny: Some(v),
798                except,
799            },
800            _ => Self {
801                allow: None,
802                deny: None,
803                except,
804            },
805        }
806    }
807
808    fn is_inherit_all(&self) -> bool {
809        self.allow.is_none() && self.deny.is_none() && self.except.is_empty()
810    }
811}
812
813#[derive(Serialize)]
814struct WritablePermissions<'a> {
815    #[serde(skip_serializing_if = "Vec::is_empty")]
816    secrets: &'a Vec<String>,
817    max_turns: u32,
818    background: bool,
819    timeout_secs: u64,
820    ttl_secs: u64,
821    permission_mode: PermissionMode,
822    #[serde(skip_serializing_if = "std::ops::Not::not")]
823    worktree: bool,
824}
825
826impl<'a> WritablePermissions<'a> {
827    fn from_def(p: &'a SubAgentPermissions) -> Self {
828        Self {
829            secrets: &p.secrets,
830            max_turns: p.max_turns,
831            background: p.background,
832            timeout_secs: p.timeout_secs,
833            ttl_secs: p.ttl_secs,
834            permission_mode: p.permission_mode,
835            worktree: p.worktree,
836        }
837    }
838
839    fn is_default(&self) -> bool {
840        self.secrets.is_empty()
841            && self.max_turns == default_max_turns()
842            && !self.background
843            && self.timeout_secs == default_timeout()
844            && self.ttl_secs == default_ttl()
845            && self.permission_mode == PermissionMode::Default
846    }
847}
848
849impl SubAgentDef {
850    /// Serialize the definition to YAML frontmatter + markdown body.
851    ///
852    /// Uses `WritableRawDef` (with correct `tools.except` nesting) to avoid the
853    /// IMP-CRIT-04 serde asymmetry. The result can be re-parsed with `SubAgentDef::parse`.
854    ///
855    /// # Panics
856    ///
857    /// Panics if `serde_norway` serialization fails (should not happen for valid structs).
858    #[must_use]
859    pub fn serialize_to_markdown(&self) -> String {
860        let tools = WritableToolPolicy::from_def(&self.tools, &self.disallowed_tools);
861        let permissions = WritablePermissions::from_def(&self.permissions);
862
863        let writable = WritableRawDef {
864            name: &self.name,
865            description: &self.description,
866            model: self.model.as_ref(),
867            tools,
868            permissions,
869            skills: &self.skills,
870            hooks: &self.hooks,
871            memory: self.memory,
872        };
873
874        let yaml = serde_norway::to_string(&writable).expect("serialization cannot fail");
875        if self.system_prompt.is_empty() {
876            format!("---\n{yaml}---\n")
877        } else {
878            format!("---\n{yaml}---\n\n{}\n", self.system_prompt)
879        }
880    }
881
882    /// Write definition to `{dir}/{self.name}.md` atomically using temp+rename.
883    ///
884    /// Creates parent directories if needed. Uses `tempfile::NamedTempFile` in the same
885    /// directory for automatic cleanup on failure.
886    ///
887    /// # Errors
888    ///
889    /// Returns [`SubAgentError::Invalid`] if the agent name fails validation (prevents path traversal).
890    /// Returns [`SubAgentError::Io`] if directory creation, write, or rename fails.
891    pub fn save_atomic(&self, dir: &Path) -> Result<PathBuf, SubAgentError> {
892        if !AGENT_NAME_RE.is_match(&self.name) {
893            return Err(SubAgentError::Invalid(format!(
894                "name '{}' is invalid: must match ^[a-zA-Z0-9][a-zA-Z0-9_-]{{0,63}}$",
895                self.name
896            )));
897        }
898        std::fs::create_dir_all(dir).map_err(|e| SubAgentError::Io {
899            path: dir.display().to_string(),
900            reason: format!("cannot create directory: {e}"),
901        })?;
902
903        let content = self.serialize_to_markdown();
904        let target = dir.join(format!("{}.md", self.name));
905
906        let mut tmp = NamedTempFile::new_in(dir).map_err(|e| SubAgentError::Io {
907            path: dir.display().to_string(),
908            reason: format!("cannot create temp file: {e}"),
909        })?;
910
911        std::io::Write::write_all(&mut tmp, content.as_bytes()).map_err(|e| SubAgentError::Io {
912            path: dir.display().to_string(),
913            reason: format!("cannot write temp file: {e}"),
914        })?;
915
916        tmp.persist(&target).map_err(|e| SubAgentError::Io {
917            path: target.display().to_string(),
918            reason: format!("cannot rename temp file: {e}"),
919        })?;
920
921        Ok(target)
922    }
923
924    /// Delete a definition file from disk.
925    ///
926    /// # Errors
927    ///
928    /// Returns [`SubAgentError::Io`] if the file does not exist or cannot be removed.
929    pub fn delete_file(path: &Path) -> Result<(), SubAgentError> {
930        std::fs::remove_file(path).map_err(|e| SubAgentError::Io {
931            path: path.display().to_string(),
932            reason: e.to_string(),
933        })
934    }
935
936    /// Create a minimal definition suitable for the create wizard.
937    ///
938    /// Sets sensible defaults: `InheritAll` tools, default permissions, empty system prompt.
939    #[must_use]
940    pub fn default_template(name: impl Into<String>, description: impl Into<String>) -> Self {
941        Self {
942            name: name.into(),
943            description: description.into(),
944            model: None,
945            tools: ToolPolicy::InheritAll,
946            disallowed_tools: Vec::new(),
947            permissions: SubAgentPermissions::default(),
948            skills: SkillFilter::default(),
949            hooks: SubagentHooks::default(),
950            memory: None,
951            system_prompt: String::new(),
952            source: None,
953            file_path: None,
954        }
955    }
956}
957
958// ── Tests ─────────────────────────────────────────────────────────────────────
959
960#[cfg(test)]
961mod tests {
962    #![allow(clippy::cloned_ref_to_slice_refs)]
963    use std::assert_matches;
964
965    use indoc::indoc;
966
967    use super::*;
968
969    // ── YAML fixtures (primary format) ─────────────────────────────────────────
970
971    const FULL_DEF_YAML: &str = indoc! {"
972        ---
973        name: code-reviewer
974        description: Reviews code changes for correctness and style
975        model: claude-sonnet-4-20250514
976        tools:
977          allow:
978            - shell
979            - web_scrape
980        permissions:
981          secrets:
982            - github-token
983          max_turns: 10
984          background: false
985          timeout_secs: 300
986          ttl_secs: 120
987        skills:
988          include:
989            - \"git-*\"
990            - \"rust-*\"
991          exclude:
992            - \"deploy-*\"
993        ---
994
995        You are a code reviewer. Report findings with severity.
996    "};
997
998    const MINIMAL_DEF_YAML: &str = indoc! {"
999        ---
1000        name: bot
1001        description: A bot
1002        ---
1003
1004        Do things.
1005    "};
1006
1007    // ── TOML fixtures (deprecated fallback) ────────────────────────────────────
1008
1009    const FULL_DEF_TOML: &str = indoc! {"
1010        +++
1011        name = \"code-reviewer\"
1012        description = \"Reviews code changes for correctness and style\"
1013        model = \"claude-sonnet-4-20250514\"
1014
1015        [tools]
1016        allow = [\"shell\", \"web_scrape\"]
1017
1018        [permissions]
1019        secrets = [\"github-token\"]
1020        max_turns = 10
1021        background = false
1022        timeout_secs = 300
1023        ttl_secs = 120
1024
1025        [skills]
1026        include = [\"git-*\", \"rust-*\"]
1027        exclude = [\"deploy-*\"]
1028        +++
1029
1030        You are a code reviewer. Report findings with severity.
1031    "};
1032
1033    const MINIMAL_DEF_TOML: &str = indoc! {"
1034        +++
1035        name = \"bot\"
1036        description = \"A bot\"
1037        +++
1038
1039        Do things.
1040    "};
1041
1042    // ── YAML tests ─────────────────────────────────────────────────────────────
1043
1044    #[test]
1045    fn parse_yaml_full_definition() {
1046        let def = SubAgentDef::parse(FULL_DEF_YAML).unwrap();
1047        assert_eq!(def.name, "code-reviewer");
1048        assert_eq!(
1049            def.description,
1050            "Reviews code changes for correctness and style"
1051        );
1052        assert_eq!(
1053            def.model,
1054            Some(ModelSpec::Named("claude-sonnet-4-20250514".to_owned()))
1055        );
1056        assert_matches!(def.tools, ToolPolicy::AllowList(ref v) if v == &["shell", "web_scrape"]);
1057        assert_eq!(def.permissions.max_turns, 10);
1058        assert_eq!(def.permissions.secrets, ["github-token"]);
1059        assert_eq!(def.skills.include, ["git-*", "rust-*"]);
1060        assert_eq!(def.skills.exclude, ["deploy-*"]);
1061        assert!(def.system_prompt.contains("code reviewer"));
1062    }
1063
1064    #[test]
1065    fn parse_yaml_minimal_definition() {
1066        let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1067        assert_eq!(def.name, "bot");
1068        assert_eq!(def.description, "A bot");
1069        assert!(def.model.is_none());
1070        assert_matches!(def.tools, ToolPolicy::InheritAll);
1071        assert_eq!(def.permissions.max_turns, 20);
1072        assert_eq!(def.permissions.timeout_secs, 600);
1073        assert_eq!(def.permissions.ttl_secs, 300);
1074        assert!(!def.permissions.background);
1075        assert_eq!(def.system_prompt, "Do things.");
1076    }
1077
1078    #[test]
1079    fn parse_yaml_with_dashes_in_body() {
1080        // --- in the body after the closing --- delimiter must not break the parser
1081        let content = "---\nname: agent\ndescription: desc\n---\n\nSome text\n---\nMore text\n";
1082        let def = SubAgentDef::parse(content).unwrap();
1083        assert_eq!(def.name, "agent");
1084        assert!(def.system_prompt.contains("Some text"));
1085        assert!(def.system_prompt.contains("More text"));
1086    }
1087
1088    #[test]
1089    fn parse_yaml_tool_deny_list() {
1090        let content = "---\nname: a\ndescription: b\ntools:\n  deny:\n    - shell\n---\n\nbody\n";
1091        let def = SubAgentDef::parse(content).unwrap();
1092        assert_matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["shell"]);
1093    }
1094
1095    #[test]
1096    fn parse_yaml_tool_inherit_all() {
1097        // Explicit tools section with neither allow nor deny also yields InheritAll.
1098        let content = "---\nname: a\ndescription: b\ntools: {}\n---\n\nbody\n";
1099        let def = SubAgentDef::parse(content).unwrap();
1100        assert_matches!(def.tools, ToolPolicy::InheritAll);
1101    }
1102
1103    #[test]
1104    fn parse_yaml_tool_both_specified_is_error() {
1105        let content = "---\nname: a\ndescription: b\ntools:\n  allow:\n    - x\n  deny:\n    - y\n---\n\nbody\n";
1106        let err = SubAgentDef::parse(content).unwrap_err();
1107        assert_matches!(err, SubAgentError::Invalid(_));
1108    }
1109
1110    #[test]
1111    fn parse_yaml_missing_closing_delimiter() {
1112        let err = SubAgentDef::parse("---\nname: a\ndescription: b\n").unwrap_err();
1113        assert_matches!(err, SubAgentError::Parse { .. });
1114    }
1115
1116    #[test]
1117    fn parse_yaml_crlf_line_endings() {
1118        let content = "---\r\nname: bot\r\ndescription: A bot\r\n---\r\n\r\nDo things.\r\n";
1119        let def = SubAgentDef::parse(content).unwrap();
1120        assert_eq!(def.name, "bot");
1121        assert_eq!(def.description, "A bot");
1122        assert!(!def.system_prompt.is_empty());
1123    }
1124
1125    #[test]
1126    fn parse_yaml_missing_required_field_name() {
1127        let content = "---\ndescription: b\n---\n\nbody\n";
1128        let err = SubAgentDef::parse(content).unwrap_err();
1129        assert_matches!(err, SubAgentError::Parse { .. });
1130    }
1131
1132    #[test]
1133    fn parse_yaml_missing_required_field_description() {
1134        let content = "---\nname: a\n---\n\nbody\n";
1135        let err = SubAgentDef::parse(content).unwrap_err();
1136        assert_matches!(err, SubAgentError::Parse { .. });
1137    }
1138
1139    #[test]
1140    fn parse_yaml_empty_name_is_invalid() {
1141        let content = "---\nname: \"\"\ndescription: b\n---\n\nbody\n";
1142        let err = SubAgentDef::parse(content).unwrap_err();
1143        assert_matches!(err, SubAgentError::Invalid(_));
1144    }
1145
1146    #[test]
1147    fn parse_yaml_whitespace_only_description_is_invalid() {
1148        let content = "---\nname: a\ndescription: \"   \"\n---\n\nbody\n";
1149        let err = SubAgentDef::parse(content).unwrap_err();
1150        assert_matches!(err, SubAgentError::Invalid(_));
1151    }
1152
1153    #[test]
1154    fn parse_yaml_crlf_with_numeric_fields() {
1155        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";
1156        let def = SubAgentDef::parse(content).unwrap();
1157        assert_eq!(def.permissions.max_turns, 5);
1158        assert_eq!(def.permissions.timeout_secs, 120);
1159    }
1160
1161    #[test]
1162    fn parse_yaml_no_trailing_newline() {
1163        let content = "---\nname: a\ndescription: b\n---";
1164        let def = SubAgentDef::parse(content).unwrap();
1165        assert_eq!(def.system_prompt, "");
1166    }
1167
1168    // ── TOML deprecated fallback tests ─────────────────────────────────────────
1169
1170    #[test]
1171    fn parse_full_definition() {
1172        let def = SubAgentDef::parse(FULL_DEF_TOML).unwrap();
1173        assert_eq!(def.name, "code-reviewer");
1174        assert_eq!(
1175            def.description,
1176            "Reviews code changes for correctness and style"
1177        );
1178        assert_eq!(
1179            def.model,
1180            Some(ModelSpec::Named("claude-sonnet-4-20250514".to_owned()))
1181        );
1182        assert_matches!(def.tools, ToolPolicy::AllowList(ref v) if v == &["shell", "web_scrape"]);
1183        assert_eq!(def.permissions.max_turns, 10);
1184        assert_eq!(def.permissions.secrets, ["github-token"]);
1185        assert_eq!(def.skills.include, ["git-*", "rust-*"]);
1186        assert_eq!(def.skills.exclude, ["deploy-*"]);
1187        assert!(def.system_prompt.contains("code reviewer"));
1188    }
1189
1190    #[test]
1191    fn parse_minimal_definition() {
1192        let def = SubAgentDef::parse(MINIMAL_DEF_TOML).unwrap();
1193        assert_eq!(def.name, "bot");
1194        assert_eq!(def.description, "A bot");
1195        assert!(def.model.is_none());
1196        assert_matches!(def.tools, ToolPolicy::InheritAll);
1197        assert_eq!(def.permissions.max_turns, 20);
1198        assert_eq!(def.permissions.timeout_secs, 600);
1199        assert_eq!(def.permissions.ttl_secs, 300);
1200        assert!(!def.permissions.background);
1201        assert_eq!(def.system_prompt, "Do things.");
1202    }
1203
1204    #[test]
1205    fn tool_policy_deny_list() {
1206        let content =
1207            "+++\nname = \"a\"\ndescription = \"b\"\n[tools]\ndeny = [\"shell\"]\n+++\n\nbody\n";
1208        let def = SubAgentDef::parse(content).unwrap();
1209        assert_matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["shell"]);
1210    }
1211
1212    #[test]
1213    fn tool_policy_inherit_all() {
1214        let def = SubAgentDef::parse(MINIMAL_DEF_TOML).unwrap();
1215        assert_matches!(def.tools, ToolPolicy::InheritAll);
1216    }
1217
1218    #[test]
1219    fn tool_policy_both_specified_is_error() {
1220        let content = "+++\nname = \"a\"\ndescription = \"b\"\n[tools]\nallow = [\"x\"]\ndeny = [\"y\"]\n+++\n\nbody\n";
1221        let err = SubAgentDef::parse(content).unwrap_err();
1222        assert_matches!(err, SubAgentError::Invalid(_));
1223    }
1224
1225    #[test]
1226    fn missing_opening_delimiter() {
1227        let err = SubAgentDef::parse("name = \"a\"\n+++\nbody\n").unwrap_err();
1228        assert_matches!(err, SubAgentError::Parse { .. });
1229    }
1230
1231    #[test]
1232    fn missing_closing_delimiter() {
1233        let err = SubAgentDef::parse("+++\nname = \"a\"\ndescription = \"b\"\n").unwrap_err();
1234        assert_matches!(err, SubAgentError::Parse { .. });
1235    }
1236
1237    #[test]
1238    fn missing_required_field_name() {
1239        let content = "+++\ndescription = \"b\"\n+++\n\nbody\n";
1240        let err = SubAgentDef::parse(content).unwrap_err();
1241        assert_matches!(err, SubAgentError::Parse { .. });
1242    }
1243
1244    #[test]
1245    fn missing_required_field_description() {
1246        let content = "+++\nname = \"a\"\n+++\n\nbody\n";
1247        let err = SubAgentDef::parse(content).unwrap_err();
1248        assert_matches!(err, SubAgentError::Parse { .. });
1249    }
1250
1251    #[test]
1252    fn empty_name_is_invalid() {
1253        let content = "+++\nname = \"\"\ndescription = \"b\"\n+++\n\nbody\n";
1254        let err = SubAgentDef::parse(content).unwrap_err();
1255        assert_matches!(err, SubAgentError::Invalid(_));
1256    }
1257
1258    #[test]
1259    fn load_all_deduplication_by_name() {
1260        use std::io::Write as _;
1261        let dir1 = tempfile::tempdir().unwrap();
1262        let dir2 = tempfile::tempdir().unwrap();
1263
1264        let content1 = "---\nname: bot\ndescription: from dir1\n---\n\ndir1 prompt\n";
1265        let content2 = "---\nname: bot\ndescription: from dir2\n---\n\ndir2 prompt\n";
1266
1267        let mut f1 = std::fs::File::create(dir1.path().join("bot.md")).unwrap();
1268        f1.write_all(content1.as_bytes()).unwrap();
1269
1270        let mut f2 = std::fs::File::create(dir2.path().join("bot.md")).unwrap();
1271        f2.write_all(content2.as_bytes()).unwrap();
1272
1273        let search_dirs = vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()];
1274        let defs = SubAgentDef::load_all(&search_dirs).unwrap();
1275
1276        assert_eq!(defs.len(), 1);
1277        assert_eq!(defs[0].description, "from dir1");
1278    }
1279
1280    #[test]
1281    fn default_permissions_values() {
1282        let p = SubAgentPermissions::default();
1283        assert_eq!(p.max_turns, 20);
1284        assert_eq!(p.timeout_secs, 600);
1285        assert_eq!(p.ttl_secs, 300);
1286        assert!(!p.background);
1287        assert!(p.secrets.is_empty());
1288    }
1289
1290    #[test]
1291    fn whitespace_only_description_is_invalid() {
1292        let content = "+++\nname = \"a\"\ndescription = \"   \"\n+++\n\nbody\n";
1293        let err = SubAgentDef::parse(content).unwrap_err();
1294        assert_matches!(err, SubAgentError::Invalid(_));
1295    }
1296
1297    #[test]
1298    fn load_nonexistent_file_returns_parse_error() {
1299        let err =
1300            SubAgentDef::load(std::path::Path::new("/tmp/does-not-exist-zeph.md")).unwrap_err();
1301        assert_matches!(err, SubAgentError::Parse { .. });
1302    }
1303
1304    #[test]
1305    fn parse_crlf_line_endings() {
1306        let content =
1307            "+++\r\nname = \"bot\"\r\ndescription = \"A bot\"\r\n+++\r\n\r\nDo things.\r\n";
1308        let def = SubAgentDef::parse(content).unwrap();
1309        assert_eq!(def.name, "bot");
1310        assert_eq!(def.description, "A bot");
1311        assert!(!def.system_prompt.is_empty());
1312    }
1313
1314    #[test]
1315    fn parse_crlf_closing_delimiter() {
1316        let content = "+++\r\nname = \"bot\"\r\ndescription = \"A bot\"\r\n+++\r\nPrompt here.\r\n";
1317        let def = SubAgentDef::parse(content).unwrap();
1318        assert!(def.system_prompt.contains("Prompt here"));
1319    }
1320
1321    #[test]
1322    fn load_all_warn_and_skip_on_parse_error_for_non_cli_source() {
1323        use std::io::Write as _;
1324        let dir = tempfile::tempdir().unwrap();
1325
1326        let valid = "---\nname: good\ndescription: ok\n---\n\nbody\n";
1327        let invalid = "this is not valid frontmatter";
1328
1329        let mut f1 = std::fs::File::create(dir.path().join("a_good.md")).unwrap();
1330        f1.write_all(valid.as_bytes()).unwrap();
1331
1332        let mut f2 = std::fs::File::create(dir.path().join("b_bad.md")).unwrap();
1333        f2.write_all(invalid.as_bytes()).unwrap();
1334
1335        // Non-CLI source: bad file is warned and skipped, good file is loaded.
1336        let defs = SubAgentDef::load_all(&[dir.path().to_path_buf()]).unwrap();
1337        assert_eq!(defs.len(), 1);
1338        assert_eq!(defs[0].name, "good");
1339    }
1340
1341    #[test]
1342    fn load_all_with_sources_hard_error_for_cli_file() {
1343        use std::io::Write as _;
1344        let dir = tempfile::tempdir().unwrap();
1345
1346        let invalid = "this is not valid frontmatter";
1347        let bad_path = dir.path().join("bad.md");
1348        let mut f = std::fs::File::create(&bad_path).unwrap();
1349        f.write_all(invalid.as_bytes()).unwrap();
1350
1351        // CLI source: bad file causes hard error.
1352        let err = SubAgentDef::load_all_with_sources(
1353            std::slice::from_ref(&bad_path),
1354            std::slice::from_ref(&bad_path),
1355            None,
1356            &[],
1357        )
1358        .unwrap_err();
1359        assert_matches!(err, SubAgentError::Parse { .. });
1360    }
1361
1362    #[test]
1363    fn load_all_with_sources_max_entries_per_dir_cap() {
1364        // Create MAX_ENTRIES_PER_DIR + 10 files; only first 100 should be loaded.
1365        let dir = tempfile::tempdir().unwrap();
1366        let total = MAX_ENTRIES_PER_DIR + 10;
1367        for i in 0..total {
1368            let content =
1369                format!("---\nname: agent-{i:04}\ndescription: Agent {i}\n---\n\nBody {i}\n");
1370            std::fs::write(dir.path().join(format!("agent-{i:04}.md")), &content).unwrap();
1371        }
1372        let defs = SubAgentDef::load_all(&[dir.path().to_path_buf()]).unwrap();
1373        assert_eq!(
1374            defs.len(),
1375            MAX_ENTRIES_PER_DIR,
1376            "must cap at MAX_ENTRIES_PER_DIR=100"
1377        );
1378    }
1379
1380    #[test]
1381    fn load_with_boundary_rejects_symlink_escape() {
1382        // Create two separate dirs. Place a real file in dir_b, then create a symlink in
1383        // dir_a pointing to the file in dir_b. Loading with dir_a as boundary must fail.
1384        let dir_a = tempfile::tempdir().unwrap();
1385        let dir_b = tempfile::tempdir().unwrap();
1386
1387        let real_file = dir_b.path().join("agent.md");
1388        std::fs::write(
1389            &real_file,
1390            "---\nname: escape\ndescription: Escaped\n---\n\nBody\n",
1391        )
1392        .unwrap();
1393
1394        #[cfg(not(unix))]
1395        {
1396            // Symlink boundary test is unix-specific; skip on other platforms.
1397            let _ = (dir_a, dir_b, real_file);
1398            return;
1399        }
1400
1401        #[cfg(unix)]
1402        {
1403            let link_path = dir_a.path().join("agent.md");
1404            std::os::unix::fs::symlink(&real_file, &link_path).unwrap();
1405            let boundary = std::fs::canonicalize(dir_a.path()).unwrap();
1406            let err =
1407                SubAgentDef::load_with_boundary(&link_path, Some(&boundary), None).unwrap_err();
1408            assert!(
1409                matches!(&err, SubAgentError::Parse { reason, .. } if reason.contains("escapes allowed directory boundary")),
1410                "expected boundary violation error, got: {err}"
1411            );
1412        }
1413    }
1414
1415    #[test]
1416    fn load_all_with_sources_source_field_has_correct_scope_label() {
1417        use std::io::Write as _;
1418        // Create a dir that will be treated as the user-level dir.
1419        let user_dir = tempfile::tempdir().unwrap();
1420        let user_dir_path = user_dir.path().to_path_buf();
1421        let content = "---\nname: my-agent\ndescription: test\n---\n\nBody\n";
1422        let mut f = std::fs::File::create(user_dir_path.join("my-agent.md")).unwrap();
1423        f.write_all(content.as_bytes()).unwrap();
1424
1425        // Use user_dir as config_user_dir so scope_label returns "user".
1426        let paths = vec![user_dir_path.clone()];
1427        let defs =
1428            SubAgentDef::load_all_with_sources(&paths, &[], Some(&user_dir_path), &[]).unwrap();
1429
1430        assert_eq!(defs.len(), 1);
1431        let source = defs[0].source.as_deref().unwrap_or("");
1432        assert!(
1433            source.starts_with("user/"),
1434            "expected source to start with 'user/', got: {source}"
1435        );
1436    }
1437
1438    #[test]
1439    fn load_all_with_sources_priority_first_name_wins() {
1440        use std::io::Write as _;
1441        let dir1 = tempfile::tempdir().unwrap();
1442        let dir2 = tempfile::tempdir().unwrap();
1443
1444        // Both dirs contain an agent with the same name "bot".
1445        let content1 = "---\nname: bot\ndescription: from dir1\n---\n\ndir1 prompt\n";
1446        let content2 = "---\nname: bot\ndescription: from dir2\n---\n\ndir2 prompt\n";
1447
1448        let mut f1 = std::fs::File::create(dir1.path().join("bot.md")).unwrap();
1449        f1.write_all(content1.as_bytes()).unwrap();
1450        let mut f2 = std::fs::File::create(dir2.path().join("bot.md")).unwrap();
1451        f2.write_all(content2.as_bytes()).unwrap();
1452
1453        // dir1 is first (higher priority), dir2 is second.
1454        let paths = vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()];
1455        let defs = SubAgentDef::load_all_with_sources(&paths, &[], None, &[]).unwrap();
1456
1457        assert_eq!(defs.len(), 1, "name collision: only first wins");
1458        assert_eq!(defs[0].description, "from dir1");
1459    }
1460
1461    #[test]
1462    fn load_all_with_sources_user_agents_dir_none_skips_gracefully() {
1463        // When config_user_dir is not provided to load_all_with_sources (None),
1464        // and the resolved ordered_paths has no user dir entry, loading must succeed.
1465        let dir = tempfile::tempdir().unwrap();
1466        let content = "---\nname: ok\ndescription: fine\n---\n\nBody\n";
1467        std::fs::write(dir.path().join("ok.md"), content).unwrap();
1468
1469        // Pass only project-level-like path — no user dir at all.
1470        let paths = vec![dir.path().to_path_buf()];
1471        let defs = SubAgentDef::load_all_with_sources(&paths, &[], None, &[]).unwrap();
1472        assert_eq!(defs.len(), 1);
1473        assert_eq!(defs[0].name, "ok");
1474    }
1475
1476    // ── PermissionMode tests ────────────────────────────────────────────────
1477
1478    #[test]
1479    fn parse_yaml_permission_mode_default_when_omitted() {
1480        let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1481        assert_eq!(def.permissions.permission_mode, PermissionMode::Default);
1482    }
1483
1484    #[test]
1485    fn parse_yaml_permission_mode_dont_ask() {
1486        let content = "---\nname: a\ndescription: b\npermissions:\n  permission_mode: dont_ask\n---\n\nbody\n";
1487        let def = SubAgentDef::parse(content).unwrap();
1488        assert_eq!(def.permissions.permission_mode, PermissionMode::DontAsk);
1489    }
1490
1491    #[test]
1492    fn parse_yaml_permission_mode_accept_edits() {
1493        let content = "---\nname: a\ndescription: b\npermissions:\n  permission_mode: accept_edits\n---\n\nbody\n";
1494        let def = SubAgentDef::parse(content).unwrap();
1495        assert_eq!(def.permissions.permission_mode, PermissionMode::AcceptEdits);
1496    }
1497
1498    #[test]
1499    fn parse_yaml_permission_mode_bypass_permissions() {
1500        let content = "---\nname: a\ndescription: b\npermissions:\n  permission_mode: bypass_permissions\n---\n\nbody\n";
1501        let def = SubAgentDef::parse(content).unwrap();
1502        assert_eq!(
1503            def.permissions.permission_mode,
1504            PermissionMode::BypassPermissions
1505        );
1506    }
1507
1508    #[test]
1509    fn parse_yaml_permission_mode_plan() {
1510        let content =
1511            "---\nname: a\ndescription: b\npermissions:\n  permission_mode: plan\n---\n\nbody\n";
1512        let def = SubAgentDef::parse(content).unwrap();
1513        assert_eq!(def.permissions.permission_mode, PermissionMode::Plan);
1514    }
1515
1516    #[test]
1517    fn parse_yaml_disallowed_tools_from_except() {
1518        let content = "---\nname: a\ndescription: b\ntools:\n  allow:\n    - shell\n    - web\n  except:\n    - shell\n---\n\nbody\n";
1519        let def = SubAgentDef::parse(content).unwrap();
1520        assert!(
1521            matches!(def.tools, ToolPolicy::AllowList(ref v) if v.contains(&"shell".to_owned()))
1522        );
1523        assert_eq!(def.disallowed_tools, ["shell"]);
1524    }
1525
1526    #[test]
1527    fn parse_yaml_disallowed_tools_empty_when_no_except() {
1528        let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1529        assert!(def.disallowed_tools.is_empty());
1530    }
1531
1532    #[test]
1533    fn parse_yaml_all_new_fields_together() {
1534        let content = indoc! {"
1535            ---
1536            name: planner
1537            description: Plans things
1538            tools:
1539              allow:
1540                - shell
1541                - web
1542              except:
1543                - dangerous
1544            permissions:
1545              max_turns: 5
1546              background: true
1547              permission_mode: plan
1548            ---
1549
1550            You are a planner.
1551        "};
1552        let def = SubAgentDef::parse(content).unwrap();
1553        assert_eq!(def.permissions.permission_mode, PermissionMode::Plan);
1554        assert!(def.permissions.background);
1555        assert_eq!(def.permissions.max_turns, 5);
1556        assert_eq!(def.disallowed_tools, ["dangerous"]);
1557    }
1558
1559    #[test]
1560    fn default_permissions_includes_permission_mode_default() {
1561        let p = SubAgentPermissions::default();
1562        assert_eq!(p.permission_mode, PermissionMode::Default);
1563    }
1564
1565    // ── #1185: additional test gaps ────────────────────────────────────────
1566
1567    #[test]
1568    fn parse_yaml_unknown_permission_mode_variant_is_error() {
1569        // Unknown variant (e.g. "banana_mode") must fail with a parse error.
1570        let content = "---\nname: a\ndescription: b\npermissions:\n  permission_mode: banana_mode\n---\n\nbody\n";
1571        let err = SubAgentDef::parse(content).unwrap_err();
1572        assert_matches!(err, SubAgentError::Parse { .. });
1573    }
1574
1575    #[test]
1576    fn parse_yaml_permission_mode_case_sensitive_camel_is_error() {
1577        // "DontAsk" (camelCase) must not parse — only snake_case is accepted.
1578        let content =
1579            "---\nname: a\ndescription: b\npermissions:\n  permission_mode: DontAsk\n---\n\nbody\n";
1580        let err = SubAgentDef::parse(content).unwrap_err();
1581        assert_matches!(err, SubAgentError::Parse { .. });
1582    }
1583
1584    #[test]
1585    fn parse_yaml_explicit_empty_except_gives_empty_disallowed_tools() {
1586        let content = "---\nname: a\ndescription: b\ntools:\n  allow:\n    - shell\n  except: []\n---\n\nbody\n";
1587        let def = SubAgentDef::parse(content).unwrap();
1588        assert!(def.disallowed_tools.is_empty());
1589    }
1590
1591    #[test]
1592    fn parse_yaml_disallowed_tools_with_deny_list_deny_wins() {
1593        // disallowed_tools (tools.except) blocks a tool even when DenyList base policy
1594        // would otherwise allow it (deny wins).
1595        let content = "---\nname: a\ndescription: b\ntools:\n  deny:\n    - dangerous\n  except:\n    - web\n---\n\nbody\n";
1596        let def = SubAgentDef::parse(content).unwrap();
1597        // base policy: DenyList blocks "dangerous", allows everything else
1598        assert_matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["dangerous"]);
1599        // disallowed_tools: "web" is additionally blocked by except
1600        assert!(def.disallowed_tools.contains(&"web".to_owned()));
1601    }
1602
1603    #[test]
1604    fn parse_toml_background_true_frontmatter() {
1605        // background: true via TOML (+++) frontmatter must parse correctly.
1606        let content = "+++\nname = \"bg-agent\"\ndescription = \"Runs in background\"\n[permissions]\nbackground = true\n+++\n\nSystem prompt.\n";
1607        let def = SubAgentDef::parse(content).unwrap();
1608        assert!(def.permissions.background);
1609        assert_eq!(def.name, "bg-agent");
1610    }
1611
1612    #[test]
1613    fn parse_yaml_unknown_top_level_field_is_error() {
1614        // deny_unknown_fields on RawSubAgentDef: typos like "permisions:" must be rejected.
1615        let content = "---\nname: a\ndescription: b\npermisions:\n  max_turns: 5\n---\n\nbody\n";
1616        let err = SubAgentDef::parse(content).unwrap_err();
1617        assert_matches!(err, SubAgentError::Parse { .. });
1618    }
1619
1620    // ── MemoryScope / memory field tests ────────────────────────────────────
1621
1622    #[test]
1623    fn parse_yaml_memory_scope_project() {
1624        let content =
1625            "---\nname: reviewer\ndescription: A reviewer\nmemory: project\n---\n\nBody.\n";
1626        let def = SubAgentDef::parse(content).unwrap();
1627        assert_eq!(def.memory, Some(MemoryScope::Project));
1628    }
1629
1630    #[test]
1631    fn parse_yaml_memory_scope_user() {
1632        let content = "---\nname: reviewer\ndescription: A reviewer\nmemory: user\n---\n\nBody.\n";
1633        let def = SubAgentDef::parse(content).unwrap();
1634        assert_eq!(def.memory, Some(MemoryScope::User));
1635    }
1636
1637    #[test]
1638    fn parse_yaml_memory_scope_local() {
1639        let content = "---\nname: reviewer\ndescription: A reviewer\nmemory: local\n---\n\nBody.\n";
1640        let def = SubAgentDef::parse(content).unwrap();
1641        assert_eq!(def.memory, Some(MemoryScope::Local));
1642    }
1643
1644    #[test]
1645    fn parse_yaml_memory_absent_gives_none() {
1646        let content = "---\nname: reviewer\ndescription: A reviewer\n---\n\nBody.\n";
1647        let def = SubAgentDef::parse(content).unwrap();
1648        assert!(def.memory.is_none());
1649    }
1650
1651    #[test]
1652    fn parse_yaml_memory_invalid_value_is_error() {
1653        let content =
1654            "---\nname: reviewer\ndescription: A reviewer\nmemory: global\n---\n\nBody.\n";
1655        let err = SubAgentDef::parse(content).unwrap_err();
1656        assert_matches!(err, SubAgentError::Parse { .. });
1657    }
1658
1659    #[test]
1660    fn memory_scope_serde_roundtrip() {
1661        for scope in [MemoryScope::User, MemoryScope::Project, MemoryScope::Local] {
1662            let json = serde_json::to_string(&scope).unwrap();
1663            let parsed: MemoryScope = serde_json::from_str(&json).unwrap();
1664            assert_eq!(parsed, scope);
1665        }
1666    }
1667
1668    // ── Agent name validation tests (CRIT-01) ────────────────────────────────
1669
1670    #[test]
1671    fn parse_yaml_name_with_unicode_is_invalid() {
1672        // Cyrillic 'а' (U+0430) looks like Latin 'a' but is rejected.
1673        let content = "---\nname: аgent\ndescription: b\n---\n\nbody\n";
1674        let err = SubAgentDef::parse(content).unwrap_err();
1675        assert_matches!(err, SubAgentError::Invalid(_));
1676    }
1677
1678    #[test]
1679    fn parse_yaml_name_with_space_is_invalid() {
1680        let content = "---\nname: my agent\ndescription: b\n---\n\nbody\n";
1681        let err = SubAgentDef::parse(content).unwrap_err();
1682        assert_matches!(err, SubAgentError::Invalid(_));
1683    }
1684
1685    #[test]
1686    fn parse_yaml_name_with_dot_is_invalid() {
1687        let content = "---\nname: my.agent\ndescription: b\n---\n\nbody\n";
1688        let err = SubAgentDef::parse(content).unwrap_err();
1689        assert_matches!(err, SubAgentError::Invalid(_));
1690    }
1691
1692    #[test]
1693    fn parse_yaml_name_single_char_is_valid() {
1694        let content = "---\nname: a\ndescription: b\n---\n\nbody\n";
1695        let def = SubAgentDef::parse(content).unwrap();
1696        assert_eq!(def.name, "a");
1697    }
1698
1699    #[test]
1700    fn parse_yaml_name_with_underscore_and_hyphen_is_valid() {
1701        let content = "---\nname: my_agent-v2\ndescription: b\n---\n\nbody\n";
1702        let def = SubAgentDef::parse(content).unwrap();
1703        assert_eq!(def.name, "my_agent-v2");
1704    }
1705
1706    // ── Serialization / save / delete / template tests ────────────────────────
1707
1708    #[test]
1709    fn default_template_valid() {
1710        let def = SubAgentDef::default_template("tester", "Runs tests");
1711        assert_eq!(def.name, "tester");
1712        assert_eq!(def.description, "Runs tests");
1713        assert!(def.model.is_none());
1714        assert_matches!(def.tools, ToolPolicy::InheritAll);
1715        assert!(def.system_prompt.is_empty());
1716    }
1717
1718    #[test]
1719    fn default_template_roundtrip() {
1720        let def = SubAgentDef::default_template("tester", "Runs tests");
1721        let markdown = def.serialize_to_markdown();
1722        let parsed = SubAgentDef::parse(&markdown).unwrap();
1723        assert_eq!(parsed.name, "tester");
1724        assert_eq!(parsed.description, "Runs tests");
1725    }
1726
1727    #[test]
1728    fn serialize_minimal() {
1729        let def = SubAgentDef::default_template("bot", "A bot");
1730        let md = def.serialize_to_markdown();
1731        assert!(md.starts_with("---\n"));
1732        assert!(md.contains("name: bot"));
1733        assert!(md.contains("description: A bot"));
1734    }
1735
1736    #[test]
1737    fn serialize_roundtrip() {
1738        let content = indoc! {"
1739            ---
1740            name: code-reviewer
1741            description: Reviews code changes for correctness and style
1742            model: claude-sonnet-4-20250514
1743            tools:
1744              allow:
1745                - shell
1746                - web_scrape
1747            permissions:
1748              max_turns: 10
1749              background: false
1750              timeout_secs: 300
1751              ttl_secs: 120
1752            skills:
1753              include:
1754                - \"git-*\"
1755                - \"rust-*\"
1756              exclude:
1757                - \"deploy-*\"
1758            ---
1759
1760            You are a code reviewer. Report findings with severity.
1761        "};
1762        let def = SubAgentDef::parse(content).unwrap();
1763        let serialized = def.serialize_to_markdown();
1764        let reparsed = SubAgentDef::parse(&serialized).unwrap();
1765        assert_eq!(reparsed.name, def.name);
1766        assert_eq!(reparsed.description, def.description);
1767        assert_eq!(reparsed.model, def.model);
1768        assert_eq!(reparsed.permissions.max_turns, def.permissions.max_turns);
1769        assert_eq!(
1770            reparsed.permissions.timeout_secs,
1771            def.permissions.timeout_secs
1772        );
1773        assert_eq!(reparsed.permissions.ttl_secs, def.permissions.ttl_secs);
1774        assert_eq!(reparsed.permissions.background, def.permissions.background);
1775        assert_eq!(
1776            reparsed.permissions.permission_mode,
1777            def.permissions.permission_mode
1778        );
1779        assert_eq!(reparsed.skills.include, def.skills.include);
1780        assert_eq!(reparsed.skills.exclude, def.skills.exclude);
1781        assert_eq!(reparsed.system_prompt, def.system_prompt);
1782        assert!(
1783            matches!(&reparsed.tools, ToolPolicy::AllowList(v) if v == &["shell", "web_scrape"])
1784        );
1785    }
1786
1787    #[test]
1788    fn serialize_roundtrip_tools_except() {
1789        let content = indoc! {"
1790            ---
1791            name: auditor
1792            description: Security auditor
1793            tools:
1794              allow:
1795                - shell
1796              except:
1797                - shell_sudo
1798                - shell_rm
1799            ---
1800
1801            Audit mode.
1802        "};
1803        let def = SubAgentDef::parse(content).unwrap();
1804        let serialized = def.serialize_to_markdown();
1805        let reparsed = SubAgentDef::parse(&serialized).unwrap();
1806        assert_eq!(reparsed.disallowed_tools, def.disallowed_tools);
1807        assert_eq!(reparsed.disallowed_tools, ["shell_sudo", "shell_rm"]);
1808        assert_matches!(&reparsed.tools, ToolPolicy::AllowList(v) if v == &["shell"]);
1809    }
1810
1811    #[test]
1812    fn serialize_all_fields() {
1813        let content = indoc! {"
1814            ---
1815            name: full-agent
1816            description: Full featured agent
1817            model: claude-opus-4-8
1818            tools:
1819              allow:
1820                - shell
1821              except:
1822                - shell_sudo
1823            permissions:
1824              max_turns: 5
1825              background: true
1826              timeout_secs: 120
1827              ttl_secs: 60
1828            skills:
1829              include:
1830                - \"git-*\"
1831            ---
1832
1833            System prompt here.
1834        "};
1835        let def = SubAgentDef::parse(content).unwrap();
1836        let md = def.serialize_to_markdown();
1837        assert!(md.contains("model: claude-opus-4-8"));
1838        assert!(md.contains("except:"));
1839        assert!(md.contains("shell_sudo"));
1840        assert!(md.contains("background: true"));
1841        assert!(md.contains("System prompt here."));
1842    }
1843
1844    #[test]
1845    fn save_atomic_creates_file() {
1846        let dir = tempfile::tempdir().unwrap();
1847        let def = SubAgentDef::default_template("myagent", "A test agent");
1848        let path = def.save_atomic(dir.path()).unwrap();
1849        assert!(path.exists());
1850        assert_eq!(path.file_name().unwrap(), "myagent.md");
1851        let content = std::fs::read_to_string(&path).unwrap();
1852        assert!(content.contains("name: myagent"));
1853    }
1854
1855    #[test]
1856    fn save_atomic_creates_parent_dirs() {
1857        let base = tempfile::tempdir().unwrap();
1858        let nested = base.path().join("a").join("b").join("c");
1859        let def = SubAgentDef::default_template("nested", "Nested dir test");
1860        let path = def.save_atomic(&nested).unwrap();
1861        assert!(path.exists());
1862    }
1863
1864    #[test]
1865    fn save_atomic_overwrites_existing() {
1866        let dir = tempfile::tempdir().unwrap();
1867        let def1 = SubAgentDef::default_template("agent", "First description");
1868        def1.save_atomic(dir.path()).unwrap();
1869
1870        let def2 = SubAgentDef::default_template("agent", "Second description");
1871        def2.save_atomic(dir.path()).unwrap();
1872
1873        let content = std::fs::read_to_string(dir.path().join("agent.md")).unwrap();
1874        assert!(content.contains("Second description"));
1875        assert!(!content.contains("First description"));
1876    }
1877
1878    #[test]
1879    fn delete_file_removes() {
1880        let dir = tempfile::tempdir().unwrap();
1881        let def = SubAgentDef::default_template("todelete", "Will be deleted");
1882        let path = def.save_atomic(dir.path()).unwrap();
1883        assert!(path.exists());
1884        SubAgentDef::delete_file(&path).unwrap();
1885        assert!(!path.exists());
1886    }
1887
1888    #[test]
1889    fn delete_file_nonexistent_errors() {
1890        let path = std::path::PathBuf::from("/tmp/does-not-exist-zeph-test.md");
1891        let result = SubAgentDef::delete_file(&path);
1892        assert!(result.is_err());
1893        assert_matches!(result.unwrap_err(), SubAgentError::Io { .. });
1894    }
1895
1896    #[test]
1897    fn save_atomic_rejects_invalid_name() {
1898        let dir = tempfile::tempdir().unwrap();
1899        let mut def = SubAgentDef::default_template("valid-name", "desc");
1900        // Bypass default_template to inject an invalid name.
1901        def.name = "../../etc/cron.d/agent".to_owned();
1902        let result = def.save_atomic(dir.path());
1903        assert!(result.is_err());
1904        assert_matches!(result.unwrap_err(), SubAgentError::Invalid(_));
1905    }
1906
1907    #[test]
1908    fn is_valid_agent_name_accepts_valid() {
1909        assert!(super::is_valid_agent_name("reviewer"));
1910        assert!(super::is_valid_agent_name("code-reviewer"));
1911        assert!(super::is_valid_agent_name("code_reviewer"));
1912        assert!(super::is_valid_agent_name("a"));
1913        assert!(super::is_valid_agent_name("A1"));
1914    }
1915
1916    #[test]
1917    fn is_valid_agent_name_rejects_invalid() {
1918        assert!(!super::is_valid_agent_name(""));
1919        assert!(!super::is_valid_agent_name("my agent"));
1920        assert!(!super::is_valid_agent_name("../../etc"));
1921        assert!(!super::is_valid_agent_name("-starts-with-dash"));
1922        assert!(!super::is_valid_agent_name("has.dot"));
1923    }
1924}