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    #![allow(clippy::cloned_ref_to_slice_refs)]
903
904    use indoc::indoc;
905
906    use super::*;
907
908    // ── YAML fixtures (primary format) ─────────────────────────────────────────
909
910    const FULL_DEF_YAML: &str = indoc! {"
911        ---
912        name: code-reviewer
913        description: Reviews code changes for correctness and style
914        model: claude-sonnet-4-20250514
915        tools:
916          allow:
917            - shell
918            - web_scrape
919        permissions:
920          secrets:
921            - github-token
922          max_turns: 10
923          background: false
924          timeout_secs: 300
925          ttl_secs: 120
926        skills:
927          include:
928            - \"git-*\"
929            - \"rust-*\"
930          exclude:
931            - \"deploy-*\"
932        ---
933
934        You are a code reviewer. Report findings with severity.
935    "};
936
937    const MINIMAL_DEF_YAML: &str = indoc! {"
938        ---
939        name: bot
940        description: A bot
941        ---
942
943        Do things.
944    "};
945
946    // ── TOML fixtures (deprecated fallback) ────────────────────────────────────
947
948    const FULL_DEF_TOML: &str = indoc! {"
949        +++
950        name = \"code-reviewer\"
951        description = \"Reviews code changes for correctness and style\"
952        model = \"claude-sonnet-4-20250514\"
953
954        [tools]
955        allow = [\"shell\", \"web_scrape\"]
956
957        [permissions]
958        secrets = [\"github-token\"]
959        max_turns = 10
960        background = false
961        timeout_secs = 300
962        ttl_secs = 120
963
964        [skills]
965        include = [\"git-*\", \"rust-*\"]
966        exclude = [\"deploy-*\"]
967        +++
968
969        You are a code reviewer. Report findings with severity.
970    "};
971
972    const MINIMAL_DEF_TOML: &str = indoc! {"
973        +++
974        name = \"bot\"
975        description = \"A bot\"
976        +++
977
978        Do things.
979    "};
980
981    // ── YAML tests ─────────────────────────────────────────────────────────────
982
983    #[test]
984    fn parse_yaml_full_definition() {
985        let def = SubAgentDef::parse(FULL_DEF_YAML).unwrap();
986        assert_eq!(def.name, "code-reviewer");
987        assert_eq!(
988            def.description,
989            "Reviews code changes for correctness and style"
990        );
991        assert_eq!(def.model.as_deref(), Some("claude-sonnet-4-20250514"));
992        assert!(matches!(def.tools, ToolPolicy::AllowList(ref v) if v == &["shell", "web_scrape"]));
993        assert_eq!(def.permissions.max_turns, 10);
994        assert_eq!(def.permissions.secrets, ["github-token"]);
995        assert_eq!(def.skills.include, ["git-*", "rust-*"]);
996        assert_eq!(def.skills.exclude, ["deploy-*"]);
997        assert!(def.system_prompt.contains("code reviewer"));
998    }
999
1000    #[test]
1001    fn parse_yaml_minimal_definition() {
1002        let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1003        assert_eq!(def.name, "bot");
1004        assert_eq!(def.description, "A bot");
1005        assert!(def.model.is_none());
1006        assert!(matches!(def.tools, ToolPolicy::InheritAll));
1007        assert_eq!(def.permissions.max_turns, 20);
1008        assert_eq!(def.permissions.timeout_secs, 600);
1009        assert_eq!(def.permissions.ttl_secs, 300);
1010        assert!(!def.permissions.background);
1011        assert_eq!(def.system_prompt, "Do things.");
1012    }
1013
1014    #[test]
1015    fn parse_yaml_with_dashes_in_body() {
1016        // --- in the body after the closing --- delimiter must not break the parser
1017        let content = "---\nname: agent\ndescription: desc\n---\n\nSome text\n---\nMore text\n";
1018        let def = SubAgentDef::parse(content).unwrap();
1019        assert_eq!(def.name, "agent");
1020        assert!(def.system_prompt.contains("Some text"));
1021        assert!(def.system_prompt.contains("More text"));
1022    }
1023
1024    #[test]
1025    fn parse_yaml_tool_deny_list() {
1026        let content = "---\nname: a\ndescription: b\ntools:\n  deny:\n    - shell\n---\n\nbody\n";
1027        let def = SubAgentDef::parse(content).unwrap();
1028        assert!(matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["shell"]));
1029    }
1030
1031    #[test]
1032    fn parse_yaml_tool_inherit_all() {
1033        // Explicit tools section with neither allow nor deny also yields InheritAll.
1034        let content = "---\nname: a\ndescription: b\ntools: {}\n---\n\nbody\n";
1035        let def = SubAgentDef::parse(content).unwrap();
1036        assert!(matches!(def.tools, ToolPolicy::InheritAll));
1037    }
1038
1039    #[test]
1040    fn parse_yaml_tool_both_specified_is_error() {
1041        let content = "---\nname: a\ndescription: b\ntools:\n  allow:\n    - x\n  deny:\n    - y\n---\n\nbody\n";
1042        let err = SubAgentDef::parse(content).unwrap_err();
1043        assert!(matches!(err, SubAgentError::Invalid(_)));
1044    }
1045
1046    #[test]
1047    fn parse_yaml_missing_closing_delimiter() {
1048        let err = SubAgentDef::parse("---\nname: a\ndescription: b\n").unwrap_err();
1049        assert!(matches!(err, SubAgentError::Parse { .. }));
1050    }
1051
1052    #[test]
1053    fn parse_yaml_crlf_line_endings() {
1054        let content = "---\r\nname: bot\r\ndescription: A bot\r\n---\r\n\r\nDo things.\r\n";
1055        let def = SubAgentDef::parse(content).unwrap();
1056        assert_eq!(def.name, "bot");
1057        assert_eq!(def.description, "A bot");
1058        assert!(!def.system_prompt.is_empty());
1059    }
1060
1061    #[test]
1062    fn parse_yaml_missing_required_field_name() {
1063        let content = "---\ndescription: b\n---\n\nbody\n";
1064        let err = SubAgentDef::parse(content).unwrap_err();
1065        assert!(matches!(err, SubAgentError::Parse { .. }));
1066    }
1067
1068    #[test]
1069    fn parse_yaml_missing_required_field_description() {
1070        let content = "---\nname: a\n---\n\nbody\n";
1071        let err = SubAgentDef::parse(content).unwrap_err();
1072        assert!(matches!(err, SubAgentError::Parse { .. }));
1073    }
1074
1075    #[test]
1076    fn parse_yaml_empty_name_is_invalid() {
1077        let content = "---\nname: \"\"\ndescription: b\n---\n\nbody\n";
1078        let err = SubAgentDef::parse(content).unwrap_err();
1079        assert!(matches!(err, SubAgentError::Invalid(_)));
1080    }
1081
1082    #[test]
1083    fn parse_yaml_whitespace_only_description_is_invalid() {
1084        let content = "---\nname: a\ndescription: \"   \"\n---\n\nbody\n";
1085        let err = SubAgentDef::parse(content).unwrap_err();
1086        assert!(matches!(err, SubAgentError::Invalid(_)));
1087    }
1088
1089    #[test]
1090    fn parse_yaml_crlf_with_numeric_fields() {
1091        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";
1092        let def = SubAgentDef::parse(content).unwrap();
1093        assert_eq!(def.permissions.max_turns, 5);
1094        assert_eq!(def.permissions.timeout_secs, 120);
1095    }
1096
1097    #[test]
1098    fn parse_yaml_no_trailing_newline() {
1099        let content = "---\nname: a\ndescription: b\n---";
1100        let def = SubAgentDef::parse(content).unwrap();
1101        assert_eq!(def.system_prompt, "");
1102    }
1103
1104    // ── TOML deprecated fallback tests ─────────────────────────────────────────
1105
1106    #[test]
1107    fn parse_full_definition() {
1108        let def = SubAgentDef::parse(FULL_DEF_TOML).unwrap();
1109        assert_eq!(def.name, "code-reviewer");
1110        assert_eq!(
1111            def.description,
1112            "Reviews code changes for correctness and style"
1113        );
1114        assert_eq!(def.model.as_deref(), Some("claude-sonnet-4-20250514"));
1115        assert!(matches!(def.tools, ToolPolicy::AllowList(ref v) if v == &["shell", "web_scrape"]));
1116        assert_eq!(def.permissions.max_turns, 10);
1117        assert_eq!(def.permissions.secrets, ["github-token"]);
1118        assert_eq!(def.skills.include, ["git-*", "rust-*"]);
1119        assert_eq!(def.skills.exclude, ["deploy-*"]);
1120        assert!(def.system_prompt.contains("code reviewer"));
1121    }
1122
1123    #[test]
1124    fn parse_minimal_definition() {
1125        let def = SubAgentDef::parse(MINIMAL_DEF_TOML).unwrap();
1126        assert_eq!(def.name, "bot");
1127        assert_eq!(def.description, "A bot");
1128        assert!(def.model.is_none());
1129        assert!(matches!(def.tools, ToolPolicy::InheritAll));
1130        assert_eq!(def.permissions.max_turns, 20);
1131        assert_eq!(def.permissions.timeout_secs, 600);
1132        assert_eq!(def.permissions.ttl_secs, 300);
1133        assert!(!def.permissions.background);
1134        assert_eq!(def.system_prompt, "Do things.");
1135    }
1136
1137    #[test]
1138    fn tool_policy_deny_list() {
1139        let content =
1140            "+++\nname = \"a\"\ndescription = \"b\"\n[tools]\ndeny = [\"shell\"]\n+++\n\nbody\n";
1141        let def = SubAgentDef::parse(content).unwrap();
1142        assert!(matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["shell"]));
1143    }
1144
1145    #[test]
1146    fn tool_policy_inherit_all() {
1147        let def = SubAgentDef::parse(MINIMAL_DEF_TOML).unwrap();
1148        assert!(matches!(def.tools, ToolPolicy::InheritAll));
1149    }
1150
1151    #[test]
1152    fn tool_policy_both_specified_is_error() {
1153        let content = "+++\nname = \"a\"\ndescription = \"b\"\n[tools]\nallow = [\"x\"]\ndeny = [\"y\"]\n+++\n\nbody\n";
1154        let err = SubAgentDef::parse(content).unwrap_err();
1155        assert!(matches!(err, SubAgentError::Invalid(_)));
1156    }
1157
1158    #[test]
1159    fn missing_opening_delimiter() {
1160        let err = SubAgentDef::parse("name = \"a\"\n+++\nbody\n").unwrap_err();
1161        assert!(matches!(err, SubAgentError::Parse { .. }));
1162    }
1163
1164    #[test]
1165    fn missing_closing_delimiter() {
1166        let err = SubAgentDef::parse("+++\nname = \"a\"\ndescription = \"b\"\n").unwrap_err();
1167        assert!(matches!(err, SubAgentError::Parse { .. }));
1168    }
1169
1170    #[test]
1171    fn missing_required_field_name() {
1172        let content = "+++\ndescription = \"b\"\n+++\n\nbody\n";
1173        let err = SubAgentDef::parse(content).unwrap_err();
1174        assert!(matches!(err, SubAgentError::Parse { .. }));
1175    }
1176
1177    #[test]
1178    fn missing_required_field_description() {
1179        let content = "+++\nname = \"a\"\n+++\n\nbody\n";
1180        let err = SubAgentDef::parse(content).unwrap_err();
1181        assert!(matches!(err, SubAgentError::Parse { .. }));
1182    }
1183
1184    #[test]
1185    fn empty_name_is_invalid() {
1186        let content = "+++\nname = \"\"\ndescription = \"b\"\n+++\n\nbody\n";
1187        let err = SubAgentDef::parse(content).unwrap_err();
1188        assert!(matches!(err, SubAgentError::Invalid(_)));
1189    }
1190
1191    #[test]
1192    fn load_all_deduplication_by_name() {
1193        use std::io::Write as _;
1194        let dir1 = tempfile::tempdir().unwrap();
1195        let dir2 = tempfile::tempdir().unwrap();
1196
1197        let content1 = "---\nname: bot\ndescription: from dir1\n---\n\ndir1 prompt\n";
1198        let content2 = "---\nname: bot\ndescription: from dir2\n---\n\ndir2 prompt\n";
1199
1200        let mut f1 = std::fs::File::create(dir1.path().join("bot.md")).unwrap();
1201        f1.write_all(content1.as_bytes()).unwrap();
1202
1203        let mut f2 = std::fs::File::create(dir2.path().join("bot.md")).unwrap();
1204        f2.write_all(content2.as_bytes()).unwrap();
1205
1206        let search_dirs = vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()];
1207        let defs = SubAgentDef::load_all(&search_dirs).unwrap();
1208
1209        assert_eq!(defs.len(), 1);
1210        assert_eq!(defs[0].description, "from dir1");
1211    }
1212
1213    #[test]
1214    fn default_permissions_values() {
1215        let p = SubAgentPermissions::default();
1216        assert_eq!(p.max_turns, 20);
1217        assert_eq!(p.timeout_secs, 600);
1218        assert_eq!(p.ttl_secs, 300);
1219        assert!(!p.background);
1220        assert!(p.secrets.is_empty());
1221    }
1222
1223    #[test]
1224    fn whitespace_only_description_is_invalid() {
1225        let content = "+++\nname = \"a\"\ndescription = \"   \"\n+++\n\nbody\n";
1226        let err = SubAgentDef::parse(content).unwrap_err();
1227        assert!(matches!(err, SubAgentError::Invalid(_)));
1228    }
1229
1230    #[test]
1231    fn load_nonexistent_file_returns_parse_error() {
1232        let err =
1233            SubAgentDef::load(std::path::Path::new("/tmp/does-not-exist-zeph.md")).unwrap_err();
1234        assert!(matches!(err, SubAgentError::Parse { .. }));
1235    }
1236
1237    #[test]
1238    fn parse_crlf_line_endings() {
1239        let content =
1240            "+++\r\nname = \"bot\"\r\ndescription = \"A bot\"\r\n+++\r\n\r\nDo things.\r\n";
1241        let def = SubAgentDef::parse(content).unwrap();
1242        assert_eq!(def.name, "bot");
1243        assert_eq!(def.description, "A bot");
1244        assert!(!def.system_prompt.is_empty());
1245    }
1246
1247    #[test]
1248    fn parse_crlf_closing_delimiter() {
1249        let content = "+++\r\nname = \"bot\"\r\ndescription = \"A bot\"\r\n+++\r\nPrompt here.\r\n";
1250        let def = SubAgentDef::parse(content).unwrap();
1251        assert!(def.system_prompt.contains("Prompt here"));
1252    }
1253
1254    #[test]
1255    fn load_all_warn_and_skip_on_parse_error_for_non_cli_source() {
1256        use std::io::Write as _;
1257        let dir = tempfile::tempdir().unwrap();
1258
1259        let valid = "---\nname: good\ndescription: ok\n---\n\nbody\n";
1260        let invalid = "this is not valid frontmatter";
1261
1262        let mut f1 = std::fs::File::create(dir.path().join("a_good.md")).unwrap();
1263        f1.write_all(valid.as_bytes()).unwrap();
1264
1265        let mut f2 = std::fs::File::create(dir.path().join("b_bad.md")).unwrap();
1266        f2.write_all(invalid.as_bytes()).unwrap();
1267
1268        // Non-CLI source: bad file is warned and skipped, good file is loaded.
1269        let defs = SubAgentDef::load_all(&[dir.path().to_path_buf()]).unwrap();
1270        assert_eq!(defs.len(), 1);
1271        assert_eq!(defs[0].name, "good");
1272    }
1273
1274    #[test]
1275    fn load_all_with_sources_hard_error_for_cli_file() {
1276        use std::io::Write as _;
1277        let dir = tempfile::tempdir().unwrap();
1278
1279        let invalid = "this is not valid frontmatter";
1280        let bad_path = dir.path().join("bad.md");
1281        let mut f = std::fs::File::create(&bad_path).unwrap();
1282        f.write_all(invalid.as_bytes()).unwrap();
1283
1284        // CLI source: bad file causes hard error.
1285        let err = SubAgentDef::load_all_with_sources(
1286            std::slice::from_ref(&bad_path),
1287            std::slice::from_ref(&bad_path),
1288            None,
1289            &[],
1290        )
1291        .unwrap_err();
1292        assert!(matches!(err, SubAgentError::Parse { .. }));
1293    }
1294
1295    #[test]
1296    fn load_all_with_sources_max_entries_per_dir_cap() {
1297        // Create MAX_ENTRIES_PER_DIR + 10 files; only first 100 should be loaded.
1298        let dir = tempfile::tempdir().unwrap();
1299        let total = MAX_ENTRIES_PER_DIR + 10;
1300        for i in 0..total {
1301            let content =
1302                format!("---\nname: agent-{i:04}\ndescription: Agent {i}\n---\n\nBody {i}\n");
1303            std::fs::write(dir.path().join(format!("agent-{i:04}.md")), &content).unwrap();
1304        }
1305        let defs = SubAgentDef::load_all(&[dir.path().to_path_buf()]).unwrap();
1306        assert_eq!(
1307            defs.len(),
1308            MAX_ENTRIES_PER_DIR,
1309            "must cap at MAX_ENTRIES_PER_DIR=100"
1310        );
1311    }
1312
1313    #[test]
1314    fn load_with_boundary_rejects_symlink_escape() {
1315        // Create two separate dirs. Place a real file in dir_b, then create a symlink in
1316        // dir_a pointing to the file in dir_b. Loading with dir_a as boundary must fail.
1317        let dir_a = tempfile::tempdir().unwrap();
1318        let dir_b = tempfile::tempdir().unwrap();
1319
1320        let real_file = dir_b.path().join("agent.md");
1321        std::fs::write(
1322            &real_file,
1323            "---\nname: escape\ndescription: Escaped\n---\n\nBody\n",
1324        )
1325        .unwrap();
1326
1327        #[cfg(not(unix))]
1328        {
1329            // Symlink boundary test is unix-specific; skip on other platforms.
1330            let _ = (dir_a, dir_b, real_file);
1331            return;
1332        }
1333
1334        #[cfg(unix)]
1335        {
1336            let link_path = dir_a.path().join("agent.md");
1337            std::os::unix::fs::symlink(&real_file, &link_path).unwrap();
1338            let boundary = std::fs::canonicalize(dir_a.path()).unwrap();
1339            let err =
1340                SubAgentDef::load_with_boundary(&link_path, Some(&boundary), None).unwrap_err();
1341            assert!(
1342                matches!(&err, SubAgentError::Parse { reason, .. } if reason.contains("escapes allowed directory boundary")),
1343                "expected boundary violation error, got: {err}"
1344            );
1345        }
1346    }
1347
1348    #[test]
1349    fn load_all_with_sources_source_field_has_correct_scope_label() {
1350        use std::io::Write as _;
1351        // Create a dir that will be treated as the user-level dir.
1352        let user_dir = tempfile::tempdir().unwrap();
1353        let user_dir_path = user_dir.path().to_path_buf();
1354        let content = "---\nname: my-agent\ndescription: test\n---\n\nBody\n";
1355        let mut f = std::fs::File::create(user_dir_path.join("my-agent.md")).unwrap();
1356        f.write_all(content.as_bytes()).unwrap();
1357
1358        // Use user_dir as config_user_dir so scope_label returns "user".
1359        let paths = vec![user_dir_path.clone()];
1360        let defs =
1361            SubAgentDef::load_all_with_sources(&paths, &[], Some(&user_dir_path), &[]).unwrap();
1362
1363        assert_eq!(defs.len(), 1);
1364        let source = defs[0].source.as_deref().unwrap_or("");
1365        assert!(
1366            source.starts_with("user/"),
1367            "expected source to start with 'user/', got: {source}"
1368        );
1369    }
1370
1371    #[test]
1372    fn load_all_with_sources_priority_first_name_wins() {
1373        use std::io::Write as _;
1374        let dir1 = tempfile::tempdir().unwrap();
1375        let dir2 = tempfile::tempdir().unwrap();
1376
1377        // Both dirs contain an agent with the same name "bot".
1378        let content1 = "---\nname: bot\ndescription: from dir1\n---\n\ndir1 prompt\n";
1379        let content2 = "---\nname: bot\ndescription: from dir2\n---\n\ndir2 prompt\n";
1380
1381        let mut f1 = std::fs::File::create(dir1.path().join("bot.md")).unwrap();
1382        f1.write_all(content1.as_bytes()).unwrap();
1383        let mut f2 = std::fs::File::create(dir2.path().join("bot.md")).unwrap();
1384        f2.write_all(content2.as_bytes()).unwrap();
1385
1386        // dir1 is first (higher priority), dir2 is second.
1387        let paths = vec![dir1.path().to_path_buf(), dir2.path().to_path_buf()];
1388        let defs = SubAgentDef::load_all_with_sources(&paths, &[], None, &[]).unwrap();
1389
1390        assert_eq!(defs.len(), 1, "name collision: only first wins");
1391        assert_eq!(defs[0].description, "from dir1");
1392    }
1393
1394    #[test]
1395    fn load_all_with_sources_user_agents_dir_none_skips_gracefully() {
1396        // When config_user_dir is not provided to load_all_with_sources (None),
1397        // and the resolved ordered_paths has no user dir entry, loading must succeed.
1398        let dir = tempfile::tempdir().unwrap();
1399        let content = "---\nname: ok\ndescription: fine\n---\n\nBody\n";
1400        std::fs::write(dir.path().join("ok.md"), content).unwrap();
1401
1402        // Pass only project-level-like path — no user dir at all.
1403        let paths = vec![dir.path().to_path_buf()];
1404        let defs = SubAgentDef::load_all_with_sources(&paths, &[], None, &[]).unwrap();
1405        assert_eq!(defs.len(), 1);
1406        assert_eq!(defs[0].name, "ok");
1407    }
1408
1409    // ── PermissionMode tests ────────────────────────────────────────────────
1410
1411    #[test]
1412    fn parse_yaml_permission_mode_default_when_omitted() {
1413        let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1414        assert_eq!(def.permissions.permission_mode, PermissionMode::Default);
1415    }
1416
1417    #[test]
1418    fn parse_yaml_permission_mode_dont_ask() {
1419        let content = "---\nname: a\ndescription: b\npermissions:\n  permission_mode: dont_ask\n---\n\nbody\n";
1420        let def = SubAgentDef::parse(content).unwrap();
1421        assert_eq!(def.permissions.permission_mode, PermissionMode::DontAsk);
1422    }
1423
1424    #[test]
1425    fn parse_yaml_permission_mode_accept_edits() {
1426        let content = "---\nname: a\ndescription: b\npermissions:\n  permission_mode: accept_edits\n---\n\nbody\n";
1427        let def = SubAgentDef::parse(content).unwrap();
1428        assert_eq!(def.permissions.permission_mode, PermissionMode::AcceptEdits);
1429    }
1430
1431    #[test]
1432    fn parse_yaml_permission_mode_bypass_permissions() {
1433        let content = "---\nname: a\ndescription: b\npermissions:\n  permission_mode: bypass_permissions\n---\n\nbody\n";
1434        let def = SubAgentDef::parse(content).unwrap();
1435        assert_eq!(
1436            def.permissions.permission_mode,
1437            PermissionMode::BypassPermissions
1438        );
1439    }
1440
1441    #[test]
1442    fn parse_yaml_permission_mode_plan() {
1443        let content =
1444            "---\nname: a\ndescription: b\npermissions:\n  permission_mode: plan\n---\n\nbody\n";
1445        let def = SubAgentDef::parse(content).unwrap();
1446        assert_eq!(def.permissions.permission_mode, PermissionMode::Plan);
1447    }
1448
1449    #[test]
1450    fn parse_yaml_disallowed_tools_from_except() {
1451        let content = "---\nname: a\ndescription: b\ntools:\n  allow:\n    - shell\n    - web\n  except:\n    - shell\n---\n\nbody\n";
1452        let def = SubAgentDef::parse(content).unwrap();
1453        assert!(
1454            matches!(def.tools, ToolPolicy::AllowList(ref v) if v.contains(&"shell".to_owned()))
1455        );
1456        assert_eq!(def.disallowed_tools, ["shell"]);
1457    }
1458
1459    #[test]
1460    fn parse_yaml_disallowed_tools_empty_when_no_except() {
1461        let def = SubAgentDef::parse(MINIMAL_DEF_YAML).unwrap();
1462        assert!(def.disallowed_tools.is_empty());
1463    }
1464
1465    #[test]
1466    fn parse_yaml_all_new_fields_together() {
1467        let content = indoc! {"
1468            ---
1469            name: planner
1470            description: Plans things
1471            tools:
1472              allow:
1473                - shell
1474                - web
1475              except:
1476                - dangerous
1477            permissions:
1478              max_turns: 5
1479              background: true
1480              permission_mode: plan
1481            ---
1482
1483            You are a planner.
1484        "};
1485        let def = SubAgentDef::parse(content).unwrap();
1486        assert_eq!(def.permissions.permission_mode, PermissionMode::Plan);
1487        assert!(def.permissions.background);
1488        assert_eq!(def.permissions.max_turns, 5);
1489        assert_eq!(def.disallowed_tools, ["dangerous"]);
1490    }
1491
1492    #[test]
1493    fn default_permissions_includes_permission_mode_default() {
1494        let p = SubAgentPermissions::default();
1495        assert_eq!(p.permission_mode, PermissionMode::Default);
1496    }
1497
1498    // ── #1185: additional test gaps ────────────────────────────────────────
1499
1500    #[test]
1501    fn parse_yaml_unknown_permission_mode_variant_is_error() {
1502        // Unknown variant (e.g. "banana_mode") must fail with a parse error.
1503        let content = "---\nname: a\ndescription: b\npermissions:\n  permission_mode: banana_mode\n---\n\nbody\n";
1504        let err = SubAgentDef::parse(content).unwrap_err();
1505        assert!(matches!(err, SubAgentError::Parse { .. }));
1506    }
1507
1508    #[test]
1509    fn parse_yaml_permission_mode_case_sensitive_camel_is_error() {
1510        // "DontAsk" (camelCase) must not parse — only snake_case is accepted.
1511        let content =
1512            "---\nname: a\ndescription: b\npermissions:\n  permission_mode: DontAsk\n---\n\nbody\n";
1513        let err = SubAgentDef::parse(content).unwrap_err();
1514        assert!(matches!(err, SubAgentError::Parse { .. }));
1515    }
1516
1517    #[test]
1518    fn parse_yaml_explicit_empty_except_gives_empty_disallowed_tools() {
1519        let content = "---\nname: a\ndescription: b\ntools:\n  allow:\n    - shell\n  except: []\n---\n\nbody\n";
1520        let def = SubAgentDef::parse(content).unwrap();
1521        assert!(def.disallowed_tools.is_empty());
1522    }
1523
1524    #[test]
1525    fn parse_yaml_disallowed_tools_with_deny_list_deny_wins() {
1526        // disallowed_tools (tools.except) blocks a tool even when DenyList base policy
1527        // would otherwise allow it (deny wins).
1528        let content = "---\nname: a\ndescription: b\ntools:\n  deny:\n    - dangerous\n  except:\n    - web\n---\n\nbody\n";
1529        let def = SubAgentDef::parse(content).unwrap();
1530        // base policy: DenyList blocks "dangerous", allows everything else
1531        assert!(matches!(def.tools, ToolPolicy::DenyList(ref v) if v == &["dangerous"]));
1532        // disallowed_tools: "web" is additionally blocked by except
1533        assert!(def.disallowed_tools.contains(&"web".to_owned()));
1534    }
1535
1536    #[test]
1537    fn parse_toml_background_true_frontmatter() {
1538        // background: true via TOML (+++) frontmatter must parse correctly.
1539        let content = "+++\nname = \"bg-agent\"\ndescription = \"Runs in background\"\n[permissions]\nbackground = true\n+++\n\nSystem prompt.\n";
1540        let def = SubAgentDef::parse(content).unwrap();
1541        assert!(def.permissions.background);
1542        assert_eq!(def.name, "bg-agent");
1543    }
1544
1545    #[test]
1546    fn parse_yaml_unknown_top_level_field_is_error() {
1547        // deny_unknown_fields on RawSubAgentDef: typos like "permisions:" must be rejected.
1548        let content = "---\nname: a\ndescription: b\npermisions:\n  max_turns: 5\n---\n\nbody\n";
1549        let err = SubAgentDef::parse(content).unwrap_err();
1550        assert!(matches!(err, SubAgentError::Parse { .. }));
1551    }
1552
1553    // ── MemoryScope / memory field tests ────────────────────────────────────
1554
1555    #[test]
1556    fn parse_yaml_memory_scope_project() {
1557        let content =
1558            "---\nname: reviewer\ndescription: A reviewer\nmemory: project\n---\n\nBody.\n";
1559        let def = SubAgentDef::parse(content).unwrap();
1560        assert_eq!(def.memory, Some(MemoryScope::Project));
1561    }
1562
1563    #[test]
1564    fn parse_yaml_memory_scope_user() {
1565        let content = "---\nname: reviewer\ndescription: A reviewer\nmemory: user\n---\n\nBody.\n";
1566        let def = SubAgentDef::parse(content).unwrap();
1567        assert_eq!(def.memory, Some(MemoryScope::User));
1568    }
1569
1570    #[test]
1571    fn parse_yaml_memory_scope_local() {
1572        let content = "---\nname: reviewer\ndescription: A reviewer\nmemory: local\n---\n\nBody.\n";
1573        let def = SubAgentDef::parse(content).unwrap();
1574        assert_eq!(def.memory, Some(MemoryScope::Local));
1575    }
1576
1577    #[test]
1578    fn parse_yaml_memory_absent_gives_none() {
1579        let content = "---\nname: reviewer\ndescription: A reviewer\n---\n\nBody.\n";
1580        let def = SubAgentDef::parse(content).unwrap();
1581        assert!(def.memory.is_none());
1582    }
1583
1584    #[test]
1585    fn parse_yaml_memory_invalid_value_is_error() {
1586        let content =
1587            "---\nname: reviewer\ndescription: A reviewer\nmemory: global\n---\n\nBody.\n";
1588        let err = SubAgentDef::parse(content).unwrap_err();
1589        assert!(matches!(err, SubAgentError::Parse { .. }));
1590    }
1591
1592    #[test]
1593    fn memory_scope_serde_roundtrip() {
1594        for scope in [MemoryScope::User, MemoryScope::Project, MemoryScope::Local] {
1595            let json = serde_json::to_string(&scope).unwrap();
1596            let parsed: MemoryScope = serde_json::from_str(&json).unwrap();
1597            assert_eq!(parsed, scope);
1598        }
1599    }
1600
1601    // ── Agent name validation tests (CRIT-01) ────────────────────────────────
1602
1603    #[test]
1604    fn parse_yaml_name_with_unicode_is_invalid() {
1605        // Cyrillic 'а' (U+0430) looks like Latin 'a' but is rejected.
1606        let content = "---\nname: аgent\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_space_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_with_dot_is_invalid() {
1620        let content = "---\nname: my.agent\ndescription: b\n---\n\nbody\n";
1621        let err = SubAgentDef::parse(content).unwrap_err();
1622        assert!(matches!(err, SubAgentError::Invalid(_)));
1623    }
1624
1625    #[test]
1626    fn parse_yaml_name_single_char_is_valid() {
1627        let content = "---\nname: a\ndescription: b\n---\n\nbody\n";
1628        let def = SubAgentDef::parse(content).unwrap();
1629        assert_eq!(def.name, "a");
1630    }
1631
1632    #[test]
1633    fn parse_yaml_name_with_underscore_and_hyphen_is_valid() {
1634        let content = "---\nname: my_agent-v2\ndescription: b\n---\n\nbody\n";
1635        let def = SubAgentDef::parse(content).unwrap();
1636        assert_eq!(def.name, "my_agent-v2");
1637    }
1638
1639    // ── Serialization / save / delete / template tests ────────────────────────
1640
1641    #[test]
1642    fn default_template_valid() {
1643        let def = SubAgentDef::default_template("tester", "Runs tests");
1644        assert_eq!(def.name, "tester");
1645        assert_eq!(def.description, "Runs tests");
1646        assert!(def.model.is_none());
1647        assert!(matches!(def.tools, ToolPolicy::InheritAll));
1648        assert!(def.system_prompt.is_empty());
1649    }
1650
1651    #[test]
1652    fn default_template_roundtrip() {
1653        let def = SubAgentDef::default_template("tester", "Runs tests");
1654        let markdown = def.serialize_to_markdown();
1655        let parsed = SubAgentDef::parse(&markdown).unwrap();
1656        assert_eq!(parsed.name, "tester");
1657        assert_eq!(parsed.description, "Runs tests");
1658    }
1659
1660    #[test]
1661    fn serialize_minimal() {
1662        let def = SubAgentDef::default_template("bot", "A bot");
1663        let md = def.serialize_to_markdown();
1664        assert!(md.starts_with("---\n"));
1665        assert!(md.contains("name: bot"));
1666        assert!(md.contains("description: A bot"));
1667    }
1668
1669    #[test]
1670    fn serialize_roundtrip() {
1671        let content = indoc! {"
1672            ---
1673            name: code-reviewer
1674            description: Reviews code changes for correctness and style
1675            model: claude-sonnet-4-20250514
1676            tools:
1677              allow:
1678                - shell
1679                - web_scrape
1680            permissions:
1681              max_turns: 10
1682              background: false
1683              timeout_secs: 300
1684              ttl_secs: 120
1685            skills:
1686              include:
1687                - \"git-*\"
1688                - \"rust-*\"
1689              exclude:
1690                - \"deploy-*\"
1691            ---
1692
1693            You are a code reviewer. Report findings with severity.
1694        "};
1695        let def = SubAgentDef::parse(content).unwrap();
1696        let serialized = def.serialize_to_markdown();
1697        let reparsed = SubAgentDef::parse(&serialized).unwrap();
1698        assert_eq!(reparsed.name, def.name);
1699        assert_eq!(reparsed.description, def.description);
1700        assert_eq!(reparsed.model, def.model);
1701        assert_eq!(reparsed.permissions.max_turns, def.permissions.max_turns);
1702        assert_eq!(
1703            reparsed.permissions.timeout_secs,
1704            def.permissions.timeout_secs
1705        );
1706        assert_eq!(reparsed.permissions.ttl_secs, def.permissions.ttl_secs);
1707        assert_eq!(reparsed.permissions.background, def.permissions.background);
1708        assert_eq!(
1709            reparsed.permissions.permission_mode,
1710            def.permissions.permission_mode
1711        );
1712        assert_eq!(reparsed.skills.include, def.skills.include);
1713        assert_eq!(reparsed.skills.exclude, def.skills.exclude);
1714        assert_eq!(reparsed.system_prompt, def.system_prompt);
1715        assert!(
1716            matches!(&reparsed.tools, ToolPolicy::AllowList(v) if v == &["shell", "web_scrape"])
1717        );
1718    }
1719
1720    #[test]
1721    fn serialize_roundtrip_tools_except() {
1722        let content = indoc! {"
1723            ---
1724            name: auditor
1725            description: Security auditor
1726            tools:
1727              allow:
1728                - shell
1729              except:
1730                - shell_sudo
1731                - shell_rm
1732            ---
1733
1734            Audit mode.
1735        "};
1736        let def = SubAgentDef::parse(content).unwrap();
1737        let serialized = def.serialize_to_markdown();
1738        let reparsed = SubAgentDef::parse(&serialized).unwrap();
1739        assert_eq!(reparsed.disallowed_tools, def.disallowed_tools);
1740        assert_eq!(reparsed.disallowed_tools, ["shell_sudo", "shell_rm"]);
1741        assert!(matches!(&reparsed.tools, ToolPolicy::AllowList(v) if v == &["shell"]));
1742    }
1743
1744    #[test]
1745    fn serialize_all_fields() {
1746        let content = indoc! {"
1747            ---
1748            name: full-agent
1749            description: Full featured agent
1750            model: claude-opus-4-6
1751            tools:
1752              allow:
1753                - shell
1754              except:
1755                - shell_sudo
1756            permissions:
1757              max_turns: 5
1758              background: true
1759              timeout_secs: 120
1760              ttl_secs: 60
1761            skills:
1762              include:
1763                - \"git-*\"
1764            ---
1765
1766            System prompt here.
1767        "};
1768        let def = SubAgentDef::parse(content).unwrap();
1769        let md = def.serialize_to_markdown();
1770        assert!(md.contains("model: claude-opus-4-6"));
1771        assert!(md.contains("except:"));
1772        assert!(md.contains("shell_sudo"));
1773        assert!(md.contains("background: true"));
1774        assert!(md.contains("System prompt here."));
1775    }
1776
1777    #[test]
1778    fn save_atomic_creates_file() {
1779        let dir = tempfile::tempdir().unwrap();
1780        let def = SubAgentDef::default_template("myagent", "A test agent");
1781        let path = def.save_atomic(dir.path()).unwrap();
1782        assert!(path.exists());
1783        assert_eq!(path.file_name().unwrap(), "myagent.md");
1784        let content = std::fs::read_to_string(&path).unwrap();
1785        assert!(content.contains("name: myagent"));
1786    }
1787
1788    #[test]
1789    fn save_atomic_creates_parent_dirs() {
1790        let base = tempfile::tempdir().unwrap();
1791        let nested = base.path().join("a").join("b").join("c");
1792        let def = SubAgentDef::default_template("nested", "Nested dir test");
1793        let path = def.save_atomic(&nested).unwrap();
1794        assert!(path.exists());
1795    }
1796
1797    #[test]
1798    fn save_atomic_overwrites_existing() {
1799        let dir = tempfile::tempdir().unwrap();
1800        let def1 = SubAgentDef::default_template("agent", "First description");
1801        def1.save_atomic(dir.path()).unwrap();
1802
1803        let def2 = SubAgentDef::default_template("agent", "Second description");
1804        def2.save_atomic(dir.path()).unwrap();
1805
1806        let content = std::fs::read_to_string(dir.path().join("agent.md")).unwrap();
1807        assert!(content.contains("Second description"));
1808        assert!(!content.contains("First description"));
1809    }
1810
1811    #[test]
1812    fn delete_file_removes() {
1813        let dir = tempfile::tempdir().unwrap();
1814        let def = SubAgentDef::default_template("todelete", "Will be deleted");
1815        let path = def.save_atomic(dir.path()).unwrap();
1816        assert!(path.exists());
1817        SubAgentDef::delete_file(&path).unwrap();
1818        assert!(!path.exists());
1819    }
1820
1821    #[test]
1822    fn delete_file_nonexistent_errors() {
1823        let path = std::path::PathBuf::from("/tmp/does-not-exist-zeph-test.md");
1824        let result = SubAgentDef::delete_file(&path);
1825        assert!(result.is_err());
1826        assert!(matches!(result.unwrap_err(), SubAgentError::Io { .. }));
1827    }
1828
1829    #[test]
1830    fn save_atomic_rejects_invalid_name() {
1831        let dir = tempfile::tempdir().unwrap();
1832        let mut def = SubAgentDef::default_template("valid-name", "desc");
1833        // Bypass default_template to inject an invalid name.
1834        def.name = "../../etc/cron.d/agent".to_owned();
1835        let result = def.save_atomic(dir.path());
1836        assert!(result.is_err());
1837        assert!(matches!(result.unwrap_err(), SubAgentError::Invalid(_)));
1838    }
1839
1840    #[test]
1841    fn is_valid_agent_name_accepts_valid() {
1842        assert!(super::is_valid_agent_name("reviewer"));
1843        assert!(super::is_valid_agent_name("code-reviewer"));
1844        assert!(super::is_valid_agent_name("code_reviewer"));
1845        assert!(super::is_valid_agent_name("a"));
1846        assert!(super::is_valid_agent_name("A1"));
1847    }
1848
1849    #[test]
1850    fn is_valid_agent_name_rejects_invalid() {
1851        assert!(!super::is_valid_agent_name(""));
1852        assert!(!super::is_valid_agent_name("my agent"));
1853        assert!(!super::is_valid_agent_name("../../etc"));
1854        assert!(!super::is_valid_agent_name("-starts-with-dash"));
1855        assert!(!super::is_valid_agent_name("has.dot"));
1856    }
1857}