Skip to main content

zeph_core/subagent/
def.rs

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