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