Skip to main content

clawft_core/agent/
agents.rs

1//! Agent definitions, loading, and registry.
2//!
3//! An *agent definition* describes a custom agent persona: its name,
4//! system prompt, model override, allowed tools, and optional skills.
5//! Definitions are stored as YAML or JSON files and discovered from a
6//! 3-level directory hierarchy:
7//!
8//! 1. **Workspace** agents (`<workspace>/agents/`) -- highest priority
9//! 2. **User** agents (`~/.clawft/agents/`) -- user-global agents
10//! 3. **Built-in** agents -- compiled into the binary
11//!
12//! When the same agent name appears at multiple levels, the higher-priority
13//! level wins (workspace > user > built-in).
14//!
15//! # Directory layout
16//!
17//! ```text
18//! agents/
19//! +-- researcher/
20//! |   +-- agent.yaml   # or agent.json
21//! +-- code_reviewer/
22//!     +-- agent.yaml
23//! ```
24//!
25//! A single-file agent is also supported: `agents/researcher.yaml`.
26
27use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29
30use serde::{Deserialize, Serialize};
31use tracing::{debug, warn};
32
33use clawft_types::{ClawftError, Result};
34
35use crate::security::{
36    MAX_AGENT_FILE_SIZE, validate_directory_name, validate_file_size, validate_model_string,
37};
38
39/// Definition of a custom agent.
40///
41/// Loaded from YAML/JSON (`agent.yaml` or `agent.json`) and used by the
42/// [`ContextBuilder`](super::context::ContextBuilder) to customise the
43/// system prompt, model selection, and skill activation for a given
44/// agent persona.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AgentDefinition {
47    /// Unique agent identifier.
48    pub name: String,
49
50    /// Human-readable description.
51    pub description: String,
52
53    /// LLM model override.  When `None`, the global default is used.
54    #[serde(default)]
55    pub model: Option<String>,
56
57    /// System prompt prepended to every LLM call for this agent.
58    #[serde(default)]
59    pub system_prompt: Option<String>,
60
61    /// Skills to activate when this agent is running.
62    #[serde(default)]
63    pub skills: Vec<String>,
64
65    /// Tool allowlist.  Empty = all tools allowed.
66    #[serde(default)]
67    pub allowed_tools: Vec<String>,
68
69    /// Maximum tool-loop turns for this agent.  When `None`, the
70    /// global default is used.
71    #[serde(default)]
72    pub max_turns: Option<u32>,
73
74    /// Template variables available for prompt expansion.
75    /// See [`render_template`](super::helpers::render_template).
76    #[serde(default)]
77    pub variables: HashMap<String, String>,
78
79    /// Source path (YAML file or directory) -- not serialised.
80    #[serde(skip)]
81    pub source_path: Option<PathBuf>,
82}
83
84/// Loads agent definitions from files and directories.
85///
86/// Supports both single-file and directory-per-agent layouts.
87/// Accepted file names inside a directory: `agent.yaml`, `agent.yml`,
88/// `agent.json`.  A standalone file is also accepted directly.
89pub struct AgentLoader;
90
91impl AgentLoader {
92    /// Load an agent definition from a single file.
93    ///
94    /// Accepts `.yaml`, `.yml`, or `.json` extensions.  The `source_path`
95    /// field on the returned definition is set to the resolved file path.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`ClawftError::PluginLoadFailed`] on I/O or parse errors.
100    pub fn load_file(path: &Path) -> Result<AgentDefinition> {
101        // SEC-SKILL-07: Check file size before reading.
102        if let Ok(meta) = std::fs::metadata(path) {
103            validate_file_size(meta.len() as usize, MAX_AGENT_FILE_SIZE, "agent")?;
104        }
105
106        let content = std::fs::read_to_string(path).map_err(|e| ClawftError::PluginLoadFailed {
107            plugin: format!("agent: cannot read {}: {e}", path.display()),
108        })?;
109
110        let mut def = Self::parse_content(&content, path)?;
111        def.source_path = Some(path.to_path_buf());
112
113        // SEC-SKILL-04: Validate model string if present.
114        if let Some(ref model) = def.model {
115            validate_model_string(model)?;
116        }
117
118        Ok(def)
119    }
120
121    /// Load an agent from a file or directory.
122    ///
123    /// If `path` is a file, it is parsed directly.  If `path` is a
124    /// directory, the loader looks for `agent.yaml`, `agent.yml`, or
125    /// `agent.json` inside it.
126    ///
127    /// # Errors
128    ///
129    /// Returns [`ClawftError::PluginLoadFailed`] when no agent file is
130    /// found or the file cannot be parsed.
131    pub fn load(path: &Path) -> Result<AgentDefinition> {
132        if path.is_file() {
133            return Self::load_file(path);
134        }
135
136        if path.is_dir() {
137            for filename in &["agent.yaml", "agent.yml", "agent.json"] {
138                let candidate = path.join(filename);
139                if candidate.is_file() {
140                    let mut def = Self::load_file(&candidate)?;
141                    def.source_path = Some(path.to_path_buf());
142                    return Ok(def);
143                }
144            }
145            return Err(ClawftError::PluginLoadFailed {
146                plugin: format!("agent: no agent.yaml/agent.json in {}", path.display()),
147            });
148        }
149
150        Err(ClawftError::PluginLoadFailed {
151            plugin: format!("agent: path does not exist: {}", path.display()),
152        })
153    }
154
155    /// Load all agent definitions from a directory of agent subdirectories
156    /// (or loose YAML/JSON files).
157    ///
158    /// Each subdirectory should contain `agent.yaml` / `agent.json`.
159    /// Loose `.yaml`/`.yml`/`.json` files at the top level are also
160    /// loaded.  Errors on individual agents are logged and skipped.
161    pub fn load_dir(dir: &Path) -> Result<Vec<AgentDefinition>> {
162        if !dir.is_dir() {
163            return Ok(Vec::new());
164        }
165
166        let entries = std::fs::read_dir(dir).map_err(|e| ClawftError::PluginLoadFailed {
167            plugin: format!("agent: cannot read dir {}: {e}", dir.display()),
168        })?;
169
170        let mut defs = Vec::new();
171        for entry in entries {
172            let entry = match entry {
173                Ok(e) => e,
174                Err(e) => {
175                    warn!(error = %e, "failed to read directory entry");
176                    continue;
177                }
178            };
179            let entry_path = entry.path();
180
181            // SEC-SKILL-02: Validate directory/file names.
182            let entry_name = entry.file_name();
183            let entry_name_str = entry_name.to_string_lossy();
184            if let Err(e) = validate_directory_name(&entry_name_str) {
185                warn!(
186                    path = %entry_path.display(),
187                    error = %e,
188                    "rejected agent entry with unsafe name"
189                );
190                continue;
191            }
192
193            match Self::load(&entry_path) {
194                Ok(def) => {
195                    debug!(agent = %def.name, path = %entry_path.display(), "loaded agent");
196                    defs.push(def);
197                }
198                Err(e) => {
199                    // Only warn for entries that look like they *should* be agents
200                    let looks_like_agent = entry_path.is_dir()
201                        || entry_path
202                            .extension()
203                            .is_some_and(|ext| ext == "yaml" || ext == "yml" || ext == "json");
204                    if looks_like_agent {
205                        warn!(
206                            path = %entry_path.display(),
207                            error = %e,
208                            "failed to load agent, skipping"
209                        );
210                    }
211                }
212            }
213        }
214
215        Ok(defs)
216    }
217
218    /// Parse file content as YAML or JSON based on file extension.
219    fn parse_content(content: &str, path: &Path) -> Result<AgentDefinition> {
220        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
221
222        match ext {
223            "json" => serde_json::from_str(content).map_err(|e| ClawftError::PluginLoadFailed {
224                plugin: format!("agent: invalid JSON in {}: {e}", path.display()),
225            }),
226            // YAML is the default for .yaml, .yml, and any other extension
227            _ => serde_yaml::from_str(content).map_err(|e| ClawftError::PluginLoadFailed {
228                plugin: format!("agent: invalid YAML in {}: {e}", path.display()),
229            }),
230        }
231    }
232}
233
234/// Registry of available agent definitions with 3-level discovery.
235///
236/// Priority order (highest first):
237/// 1. **Workspace** -- project-local agents
238/// 2. **User** -- user-global agents (`~/.clawft/agents/`)
239/// 3. **Built-in** -- hard-coded defaults
240///
241/// When the same name appears at multiple levels, the higher-priority
242/// definition wins.
243pub struct AgentRegistry {
244    agents: HashMap<String, AgentDefinition>,
245}
246
247impl AgentRegistry {
248    /// Discover agents from all three levels and merge into a registry.
249    ///
250    /// Built-in agents are added first, then user agents, then workspace
251    /// agents.  Each level overwrites earlier entries with the same name.
252    ///
253    /// # Arguments
254    ///
255    /// * `workspace_dir` -- Optional workspace agents directory
256    /// * `user_dir` -- Optional user-level agents directory
257    /// * `builtin_agents` -- Hard-coded agent definitions
258    pub fn discover(
259        workspace_dir: Option<&Path>,
260        user_dir: Option<&Path>,
261        builtin_agents: Vec<AgentDefinition>,
262    ) -> Result<Self> {
263        let mut agents = HashMap::new();
264
265        // Level 3: built-in (lowest priority)
266        for def in builtin_agents {
267            agents.insert(def.name.clone(), def);
268        }
269
270        // Level 2: user agents
271        if let Some(dir) = user_dir {
272            match AgentLoader::load_dir(dir) {
273                Ok(defs) => {
274                    for def in defs {
275                        debug!(
276                            agent = %def.name,
277                            level = "user",
278                            "registering user agent"
279                        );
280                        agents.insert(def.name.clone(), def);
281                    }
282                }
283                Err(e) => {
284                    warn!(error = %e, "failed to load user agents");
285                }
286            }
287        }
288
289        // Level 1: workspace agents (highest priority)
290        if let Some(dir) = workspace_dir {
291            match AgentLoader::load_dir(dir) {
292                Ok(defs) => {
293                    for def in defs {
294                        debug!(
295                            agent = %def.name,
296                            level = "workspace",
297                            "registering workspace agent (overrides lower levels)"
298                        );
299                        agents.insert(def.name.clone(), def);
300                    }
301                }
302                Err(e) => {
303                    warn!(error = %e, "failed to load workspace agents");
304                }
305            }
306        }
307
308        debug!(count = agents.len(), "agent registry populated");
309        Ok(Self { agents })
310    }
311
312    /// Create an empty registry.
313    pub fn empty() -> Self {
314        Self {
315            agents: HashMap::new(),
316        }
317    }
318
319    /// Look up an agent by name.
320    pub fn get(&self, name: &str) -> Option<&AgentDefinition> {
321        self.agents.get(name)
322    }
323
324    /// List all registered agents, sorted by name.
325    pub fn list(&self) -> Vec<&AgentDefinition> {
326        let mut defs: Vec<_> = self.agents.values().collect();
327        defs.sort_by_key(|d| &d.name);
328        defs
329    }
330
331    /// Number of registered agents.
332    pub fn len(&self) -> usize {
333        self.agents.len()
334    }
335
336    /// Whether the registry is empty.
337    pub fn is_empty(&self) -> bool {
338        self.agents.is_empty()
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use std::sync::atomic::{AtomicU64, Ordering};
346
347    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
348
349    fn temp_dir(prefix: &str) -> PathBuf {
350        let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
351        let pid = std::process::id();
352        std::env::temp_dir().join(format!("clawft_agents_test_{prefix}_{pid}_{id}"))
353    }
354
355    /// Write a YAML agent definition to a directory.
356    fn write_yaml_agent(dir: &Path, name: &str, description: &str) {
357        let agent_dir = dir.join(name);
358        std::fs::create_dir_all(&agent_dir).unwrap();
359        let yaml = format!(
360            "name: {name}\n\
361             description: {description}\n\
362             model: test-model/v1\n\
363             system_prompt: \"You are a {name} agent.\"\n\
364             skills:\n  - research\n\
365             allowed_tools:\n  - read_file\n\
366             max_turns: 5\n\
367             variables:\n  lang: rust\n"
368        );
369        std::fs::write(agent_dir.join("agent.yaml"), yaml).unwrap();
370    }
371
372    /// Write a JSON agent definition to a directory.
373    fn write_json_agent(dir: &Path, name: &str, description: &str) {
374        let agent_dir = dir.join(name);
375        std::fs::create_dir_all(&agent_dir).unwrap();
376        let json = serde_json::json!({
377            "name": name,
378            "description": description,
379            "model": "json-model/v2",
380            "system_prompt": format!("You are a {name} agent."),
381            "skills": ["coding"],
382            "allowed_tools": ["write_file"],
383            "max_turns": 10,
384            "variables": {"framework": "axum"}
385        });
386        std::fs::write(
387            agent_dir.join("agent.json"),
388            serde_json::to_string_pretty(&json).unwrap(),
389        )
390        .unwrap();
391    }
392
393    // ── AgentLoader tests ────────────────────────────────────────────
394
395    #[test]
396    fn load_yaml_agent_definition() {
397        let dir = temp_dir("load_yaml");
398        write_yaml_agent(&dir, "researcher", "Deep research agent");
399
400        let def = AgentLoader::load(&dir.join("researcher")).unwrap();
401        assert_eq!(def.name, "researcher");
402        assert_eq!(def.description, "Deep research agent");
403        assert_eq!(def.model.as_deref(), Some("test-model/v1"));
404        assert_eq!(
405            def.system_prompt.as_deref(),
406            Some("You are a researcher agent.")
407        );
408        assert_eq!(def.skills, vec!["research"]);
409        assert_eq!(def.allowed_tools, vec!["read_file"]);
410        assert_eq!(def.max_turns, Some(5));
411        assert_eq!(def.variables.get("lang").map(|s| s.as_str()), Some("rust"));
412        assert!(def.source_path.is_some());
413
414        let _ = std::fs::remove_dir_all(&dir);
415    }
416
417    #[test]
418    fn load_json_agent_definition() {
419        let dir = temp_dir("load_json");
420        write_json_agent(&dir, "coder", "Coding agent");
421
422        let def = AgentLoader::load(&dir.join("coder")).unwrap();
423        assert_eq!(def.name, "coder");
424        assert_eq!(def.description, "Coding agent");
425        assert_eq!(def.model.as_deref(), Some("json-model/v2"));
426        assert_eq!(def.skills, vec!["coding"]);
427        assert_eq!(def.allowed_tools, vec!["write_file"]);
428        assert_eq!(def.max_turns, Some(10));
429        assert_eq!(
430            def.variables.get("framework").map(|s| s.as_str()),
431            Some("axum")
432        );
433
434        let _ = std::fs::remove_dir_all(&dir);
435    }
436
437    #[test]
438    fn load_file_directly() {
439        let dir = temp_dir("load_file");
440        std::fs::create_dir_all(&dir).unwrap();
441        let yaml = "name: standalone\ndescription: Standalone agent\n";
442        let path = dir.join("standalone.yaml");
443        std::fs::write(&path, yaml).unwrap();
444
445        let def = AgentLoader::load_file(&path).unwrap();
446        assert_eq!(def.name, "standalone");
447        assert_eq!(def.source_path.unwrap(), path);
448
449        let _ = std::fs::remove_dir_all(&dir);
450    }
451
452    #[test]
453    fn load_dir_collects_all_agents() {
454        let dir = temp_dir("load_dir");
455        write_yaml_agent(&dir, "agent_a", "Agent A");
456        write_json_agent(&dir, "agent_b", "Agent B");
457
458        let mut defs = AgentLoader::load_dir(&dir).unwrap();
459        defs.sort_by(|a, b| a.name.cmp(&b.name));
460
461        assert_eq!(defs.len(), 2);
462        assert_eq!(defs[0].name, "agent_a");
463        assert_eq!(defs[1].name, "agent_b");
464
465        let _ = std::fs::remove_dir_all(&dir);
466    }
467
468    #[test]
469    fn load_dir_returns_empty_for_missing_dir() {
470        let dir = temp_dir("missing_dir");
471        let defs = AgentLoader::load_dir(&dir).unwrap();
472        assert!(defs.is_empty());
473    }
474
475    #[test]
476    fn load_dir_skips_invalid_entries() {
477        let dir = temp_dir("skip_invalid");
478        write_yaml_agent(&dir, "good", "Good agent");
479
480        // Create a bad agent directory
481        let bad_dir = dir.join("bad");
482        std::fs::create_dir_all(&bad_dir).unwrap();
483        std::fs::write(bad_dir.join("agent.yaml"), "{{invalid yaml").unwrap();
484
485        let defs = AgentLoader::load_dir(&dir).unwrap();
486        assert_eq!(defs.len(), 1);
487        assert_eq!(defs[0].name, "good");
488
489        let _ = std::fs::remove_dir_all(&dir);
490    }
491
492    #[test]
493    fn load_nonexistent_returns_error() {
494        let result = AgentLoader::load(Path::new("/tmp/does_not_exist_at_all"));
495        assert!(result.is_err());
496    }
497
498    #[test]
499    fn load_dir_with_no_agent_file_in_subdir() {
500        let dir = temp_dir("no_agent_file");
501        let sub = dir.join("empty_agent");
502        std::fs::create_dir_all(&sub).unwrap();
503        std::fs::write(sub.join("readme.md"), "not an agent").unwrap();
504
505        let defs = AgentLoader::load_dir(&dir).unwrap();
506        assert!(defs.is_empty());
507
508        let _ = std::fs::remove_dir_all(&dir);
509    }
510
511    #[test]
512    fn default_fields_when_omitted() {
513        let dir = temp_dir("defaults");
514        std::fs::create_dir_all(&dir).unwrap();
515        let yaml = "name: minimal\ndescription: Minimal agent\n";
516        std::fs::write(dir.join("agent.yaml"), yaml).unwrap();
517
518        let def = AgentLoader::load_file(&dir.join("agent.yaml")).unwrap();
519        assert_eq!(def.name, "minimal");
520        assert!(def.model.is_none());
521        assert!(def.system_prompt.is_none());
522        assert!(def.skills.is_empty());
523        assert!(def.allowed_tools.is_empty());
524        assert!(def.max_turns.is_none());
525        assert!(def.variables.is_empty());
526
527        let _ = std::fs::remove_dir_all(&dir);
528    }
529
530    // ── AgentRegistry tests ──────────────────────────────────────────
531
532    #[test]
533    fn registry_3_level_priority() {
534        let workspace_dir = temp_dir("reg_workspace");
535        let user_dir = temp_dir("reg_user");
536
537        // Built-in: agent "shared" with description "builtin"
538        let builtin = vec![AgentDefinition {
539            name: "shared".into(),
540            description: "builtin".into(),
541            model: None,
542            system_prompt: None,
543            skills: vec![],
544            allowed_tools: vec![],
545            max_turns: None,
546            variables: HashMap::new(),
547            source_path: None,
548        }];
549
550        // User level: agent "shared" with description "user"
551        write_yaml_agent(&user_dir, "shared", "user");
552
553        // Workspace level: agent "shared" with description "workspace"
554        write_yaml_agent(&workspace_dir, "shared", "workspace");
555
556        let registry =
557            AgentRegistry::discover(Some(&workspace_dir), Some(&user_dir), builtin).unwrap();
558
559        // Workspace has highest priority
560        let agent = registry.get("shared").unwrap();
561        assert_eq!(agent.description, "workspace");
562
563        let _ = std::fs::remove_dir_all(&workspace_dir);
564        let _ = std::fs::remove_dir_all(&user_dir);
565    }
566
567    #[test]
568    fn registry_user_overrides_builtin() {
569        let user_dir = temp_dir("reg_user_override");
570
571        let builtin = vec![AgentDefinition {
572            name: "helper".into(),
573            description: "builtin helper".into(),
574            model: None,
575            system_prompt: None,
576            skills: vec![],
577            allowed_tools: vec![],
578            max_turns: None,
579            variables: HashMap::new(),
580            source_path: None,
581        }];
582
583        write_yaml_agent(&user_dir, "helper", "user helper");
584
585        let registry = AgentRegistry::discover(None, Some(&user_dir), builtin).unwrap();
586        let agent = registry.get("helper").unwrap();
587        assert_eq!(agent.description, "user helper");
588
589        let _ = std::fs::remove_dir_all(&user_dir);
590    }
591
592    #[test]
593    fn registry_list_returns_sorted() {
594        let builtin = vec![
595            AgentDefinition {
596                name: "zulu".into(),
597                description: "Z agent".into(),
598                model: None,
599                system_prompt: None,
600                skills: vec![],
601                allowed_tools: vec![],
602                max_turns: None,
603                variables: HashMap::new(),
604                source_path: None,
605            },
606            AgentDefinition {
607                name: "alpha".into(),
608                description: "A agent".into(),
609                model: None,
610                system_prompt: None,
611                skills: vec![],
612                allowed_tools: vec![],
613                max_turns: None,
614                variables: HashMap::new(),
615                source_path: None,
616            },
617        ];
618
619        let registry = AgentRegistry::discover(None, None, builtin).unwrap();
620        let list = registry.list();
621        assert_eq!(list.len(), 2);
622        assert_eq!(list[0].name, "alpha");
623        assert_eq!(list[1].name, "zulu");
624    }
625
626    #[test]
627    fn registry_empty() {
628        let registry = AgentRegistry::empty();
629        assert!(registry.is_empty());
630        assert_eq!(registry.len(), 0);
631        assert!(registry.get("anything").is_none());
632        assert!(registry.list().is_empty());
633    }
634
635    #[test]
636    fn registry_get_missing_returns_none() {
637        let registry = AgentRegistry::discover(None, None, vec![]).unwrap();
638        assert!(registry.get("nonexistent").is_none());
639    }
640
641    #[test]
642    fn agent_definition_serde_roundtrip() {
643        let def = AgentDefinition {
644            name: "test".into(),
645            description: "Test agent".into(),
646            model: Some("model/v1".into()),
647            system_prompt: Some("You are test.".into()),
648            skills: vec!["research".into()],
649            allowed_tools: vec!["read_file".into()],
650            max_turns: Some(15),
651            variables: {
652                let mut m = HashMap::new();
653                m.insert("lang".into(), "rust".into());
654                m
655            },
656            source_path: Some(PathBuf::from("/tmp/test")),
657        };
658
659        let json = serde_json::to_string(&def).unwrap();
660        let restored: AgentDefinition = serde_json::from_str(&json).unwrap();
661
662        assert_eq!(restored.name, "test");
663        assert_eq!(restored.description, "Test agent");
664        assert_eq!(restored.model.as_deref(), Some("model/v1"));
665        assert_eq!(restored.skills, vec!["research"]);
666        assert_eq!(restored.max_turns, Some(15));
667        // source_path is #[serde(skip)] so should be None
668        assert!(restored.source_path.is_none());
669    }
670
671    #[test]
672    fn load_agent_yml_extension() {
673        let dir = temp_dir("yml_ext");
674        let agent_dir = dir.join("yml_agent");
675        std::fs::create_dir_all(&agent_dir).unwrap();
676        let yaml = "name: yml_agent\ndescription: Uses .yml extension\n";
677        std::fs::write(agent_dir.join("agent.yml"), yaml).unwrap();
678
679        let def = AgentLoader::load(&agent_dir).unwrap();
680        assert_eq!(def.name, "yml_agent");
681
682        let _ = std::fs::remove_dir_all(&dir);
683    }
684
685    // ── SEC-SKILL-02: Directory traversal rejected in load_dir ──────
686
687    #[test]
688    fn sec_skill_02_agent_traversal_rejected() {
689        let dir = temp_dir("sec02_agent_traversal");
690
691        // Create a directory with path traversal in its name.
692        let evil_dir = dir.join("..%2Fevil");
693        std::fs::create_dir_all(&evil_dir).unwrap();
694        let yaml = "name: evil\ndescription: Evil agent\n";
695        std::fs::write(evil_dir.join("agent.yaml"), yaml).unwrap();
696
697        // Also create a valid agent.
698        write_yaml_agent(&dir, "good", "Good agent");
699
700        let defs = AgentLoader::load_dir(&dir).unwrap();
701        let names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
702        assert!(names.contains(&"good"));
703        // The evil dir has ".." in its name and should be rejected.
704
705        let _ = std::fs::remove_dir_all(&dir);
706    }
707
708    // ── SEC-SKILL-04: Model string validation ───────────────────────
709
710    #[test]
711    fn sec_skill_04_shell_metachar_model_rejected() {
712        let dir = temp_dir("sec04_model");
713        let agent_dir = dir.join("evil_model");
714        std::fs::create_dir_all(&agent_dir).unwrap();
715        let yaml = "name: evil_model\ndescription: Has evil model\nmodel: \"; rm -rf /\"\n";
716        std::fs::write(agent_dir.join("agent.yaml"), yaml).unwrap();
717
718        let result = AgentLoader::load(&agent_dir);
719        assert!(result.is_err());
720        let err = result.unwrap_err().to_string();
721        assert!(
722            err.contains("forbidden character") || err.contains("security violation"),
723            "expected security error, got: {err}"
724        );
725
726        let _ = std::fs::remove_dir_all(&dir);
727    }
728
729    #[test]
730    fn sec_skill_04_valid_model_accepted() {
731        let dir = temp_dir("sec04_valid");
732        let agent_dir = dir.join("valid_model");
733        std::fs::create_dir_all(&agent_dir).unwrap();
734        let yaml = "name: valid_model\ndescription: Valid model\nmodel: openai/gpt-4o\n";
735        std::fs::write(agent_dir.join("agent.yaml"), yaml).unwrap();
736
737        let def = AgentLoader::load(&agent_dir).unwrap();
738        assert_eq!(def.model.as_deref(), Some("openai/gpt-4o"));
739
740        let _ = std::fs::remove_dir_all(&dir);
741    }
742
743    // ── SEC-SKILL-07: Agent file size validation ────────────────────
744
745    #[test]
746    fn sec_skill_07_oversized_agent_rejected() {
747        let dir = temp_dir("sec07_agent_size");
748        let agent_dir = dir.join("big_agent");
749        std::fs::create_dir_all(&agent_dir).unwrap();
750
751        // Create a YAML file larger than 10 KB.
752        let padding = "x".repeat(11 * 1024);
753        let yaml = format!("name: big_agent\ndescription: {padding}\n");
754        std::fs::write(agent_dir.join("agent.yaml"), yaml).unwrap();
755
756        let result = AgentLoader::load(&agent_dir);
757        assert!(result.is_err());
758        let err = result.unwrap_err().to_string();
759        assert!(
760            err.contains("exceeds") || err.contains("size"),
761            "expected size error, got: {err}"
762        );
763
764        let _ = std::fs::remove_dir_all(&dir);
765    }
766}