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