Skip to main content

zeph_subagent/
def.rs

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