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