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