Skip to main content

smith_config/
behavior.rs

1//! Behavior pack configuration management
2//!
3//! This module handles loading, validation, and hot-reloading of behavior packs
4//! that define which capabilities are enabled for different execution modes.
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::time::{Duration, SystemTime};
11
12/// Execution mode for behavior packs
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "snake_case")]
15#[derive(Default)]
16pub enum BehaviorMode {
17    /// Strict mode: No direct atom usage, macros/playbooks only
18    #[default]
19    Strict,
20    /// Explore mode: Direct atom usage allowed with risk/cost multipliers  
21    Explore,
22    /// Shadow mode: No actual execution, logging and metrics only
23    Shadow,
24}
25
26/// Capability enablement configuration
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct EnabledCapabilities {
29    /// Enabled atomic capabilities
30    pub atoms: Vec<String>,
31    /// Enabled macro capabilities
32    pub macros: Vec<String>,
33    /// Enabled playbook capabilities
34    pub playbooks: Vec<String>,
35}
36
37/// Parameter overrides for specific capabilities
38pub type CapabilityParams = HashMap<String, serde_json::Value>;
39
40/// Guard configuration for capability layers
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct GuardConfig {
43    /// Atom-level guards
44    pub atoms: Option<AtomGuards>,
45    /// Macro-level guards
46    pub macros: Option<MacroGuards>,
47    /// Playbook-level guards
48    pub playbooks: Option<PlaybookGuards>,
49}
50
51impl Default for GuardConfig {
52    fn default() -> Self {
53        Self {
54            atoms: Some(AtomGuards::default()),
55            macros: Some(MacroGuards::default()),
56            playbooks: Some(PlaybookGuards::default()),
57        }
58    }
59}
60
61/// Guards specific to atomic capabilities
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct AtomGuards {
64    /// Default maximum bytes for file operations
65    pub default_max_bytes: u64,
66    /// Require justification for direct atom usage
67    pub require_justification: bool,
68}
69
70impl Default for AtomGuards {
71    fn default() -> Self {
72        Self {
73            default_max_bytes: 1048576, // 1MB default
74            require_justification: true,
75        }
76    }
77}
78
79/// Guards specific to macro capabilities
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct MacroGuards {
82    /// Template validation level
83    pub template_validation: ValidationLevel,
84}
85
86impl Default for MacroGuards {
87    fn default() -> Self {
88        Self {
89            template_validation: ValidationLevel::Strict,
90        }
91    }
92}
93
94/// Guards specific to playbook capabilities
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct PlaybookGuards {
97    /// Allow parallel execution of playbook steps
98    pub parallel_execution: bool,
99    /// Maximum number of steps in a playbook
100    pub max_steps: u32,
101}
102
103impl Default for PlaybookGuards {
104    fn default() -> Self {
105        Self {
106            parallel_execution: false,
107            max_steps: 10,
108        }
109    }
110}
111
112/// Validation strictness levels
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum ValidationLevel {
116    Strict,
117    Permissive,
118}
119
120/// Behavior pack configuration
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct BehaviorPack {
123    /// Unique name for this behavior pack
124    pub name: String,
125
126    /// Execution mode
127    pub mode: BehaviorMode,
128
129    /// Enabled capabilities
130    pub enable: EnabledCapabilities,
131
132    /// Parameter overrides for specific capabilities
133    #[serde(default)]
134    pub params: CapabilityParams,
135
136    /// Guard configuration
137    #[serde(default)]
138    pub guards: GuardConfig,
139}
140
141/// Behavior pack manager with hot-reload support
142#[derive(Debug)]
143pub struct BehaviorPackManager {
144    /// Directory containing behavior pack YAML files
145    config_dir: PathBuf,
146    /// Currently loaded behavior packs
147    packs: HashMap<String, BehaviorPack>,
148    /// Last modification times for hot-reload detection
149    file_times: HashMap<PathBuf, SystemTime>,
150    /// Polling interval for hot-reload
151    poll_interval: Duration,
152}
153
154impl BehaviorPackManager {
155    /// Create a new behavior pack manager
156    pub fn new<P: AsRef<Path>>(config_dir: P) -> Self {
157        Self {
158            config_dir: config_dir.as_ref().to_path_buf(),
159            packs: HashMap::new(),
160            file_times: HashMap::new(),
161            poll_interval: Duration::from_secs(5), // 5 second polling as specified
162        }
163    }
164
165    /// Load all behavior packs from the config directory
166    pub fn load_all(&mut self) -> Result<()> {
167        let entries = std::fs::read_dir(&self.config_dir).with_context(|| {
168            format!(
169                "Failed to read behavior config directory: {}",
170                self.config_dir.display()
171            )
172        })?;
173
174        for entry in entries {
175            let entry = entry.context("Failed to read directory entry")?;
176            let path = entry.path();
177
178            if path.extension().and_then(|s| s.to_str()) == Some("yaml")
179                || path.extension().and_then(|s| s.to_str()) == Some("yml")
180            {
181                self.load_pack(&path)?;
182            }
183        }
184
185        Ok(())
186    }
187
188    /// Load a single behavior pack from file
189    pub fn load_pack(&mut self, path: &Path) -> Result<()> {
190        let content = std::fs::read_to_string(path)
191            .with_context(|| format!("Failed to read behavior pack file: {}", path.display()))?;
192
193        let pack: BehaviorPack = serde_yaml::from_str(&content)
194            .with_context(|| format!("Failed to parse behavior pack YAML: {}", path.display()))?;
195
196        // Validate the behavior pack
197        pack.validate()?;
198
199        // Update file modification time
200        let metadata = std::fs::metadata(path)
201            .with_context(|| format!("Failed to get file metadata: {}", path.display()))?;
202
203        if let Ok(modified) = metadata.modified() {
204            self.file_times.insert(path.to_path_buf(), modified);
205        }
206
207        // Store the loaded pack
208        self.packs.insert(pack.name.clone(), pack);
209
210        tracing::info!("Loaded behavior pack from {}", path.display());
211        Ok(())
212    }
213
214    /// Get a behavior pack by name
215    pub fn get_pack(&self, name: &str) -> Option<&BehaviorPack> {
216        self.packs.get(name)
217    }
218
219    /// List all loaded behavior pack names
220    pub fn list_packs(&self) -> Vec<String> {
221        self.packs.keys().cloned().collect()
222    }
223
224    /// Check for file changes and reload if necessary
225    pub fn check_and_reload(&mut self) -> Result<Vec<String>> {
226        let mut reloaded = Vec::new();
227
228        let entries = match std::fs::read_dir(&self.config_dir) {
229            Ok(entries) => entries,
230            Err(_) => return Ok(reloaded), // Directory doesn't exist or not readable
231        };
232
233        for entry in entries {
234            let entry = entry.context("Failed to read directory entry")?;
235            let path = entry.path();
236
237            if path.extension().and_then(|s| s.to_str()) == Some("yaml")
238                || path.extension().and_then(|s| s.to_str()) == Some("yml")
239            {
240                let metadata = match std::fs::metadata(&path) {
241                    Ok(metadata) => metadata,
242                    Err(_) => continue, // File may have been deleted
243                };
244
245                if let Ok(modified) = metadata.modified() {
246                    let needs_reload = match self.file_times.get(&path) {
247                        Some(last_modified) => modified > *last_modified,
248                        None => true, // New file
249                    };
250
251                    if needs_reload {
252                        match self.load_pack(&path) {
253                            Ok(()) => {
254                                let filename = path
255                                    .file_stem()
256                                    .and_then(|s| s.to_str())
257                                    .unwrap_or("unknown")
258                                    .to_string();
259                                reloaded.push(filename);
260                                tracing::info!("Reloaded behavior pack: {}", path.display());
261                            }
262                            Err(e) => {
263                                tracing::error!(
264                                    "Failed to reload behavior pack {}: {}",
265                                    path.display(),
266                                    e
267                                );
268                                // Continue with last-known-good configuration
269                            }
270                        }
271                    }
272                }
273            }
274        }
275
276        Ok(reloaded)
277    }
278
279    /// Get the polling interval for hot-reload
280    pub fn poll_interval(&self) -> Duration {
281        self.poll_interval
282    }
283
284    /// Set the polling interval for hot-reload
285    pub fn set_poll_interval(&mut self, interval: Duration) {
286        self.poll_interval = interval;
287    }
288
289    /// Get all loaded behavior packs
290    pub fn all_packs(&self) -> &HashMap<String, BehaviorPack> {
291        &self.packs
292    }
293}
294
295impl BehaviorPack {
296    /// Validate the behavior pack configuration
297    pub fn validate(&self) -> Result<()> {
298        // Validate name is not empty
299        if self.name.is_empty() {
300            return Err(anyhow::anyhow!("Behavior pack name cannot be empty"));
301        }
302
303        // Validate mode-specific constraints
304        match self.mode {
305            BehaviorMode::Strict => {
306                if !self.enable.atoms.is_empty() {
307                    return Err(anyhow::anyhow!(
308                        "Strict mode cannot enable direct atom usage, but {} atoms were enabled",
309                        self.enable.atoms.len()
310                    ));
311                }
312            }
313            BehaviorMode::Explore => {
314                // Explore mode allows atoms but should have justification requirement
315                if let Some(ref atom_guards) = self.guards.atoms {
316                    if !atom_guards.require_justification {
317                        tracing::warn!(
318                            "Explore mode behavior pack '{}' does not require justification for atom usage",
319                            self.name
320                        );
321                    }
322                }
323            }
324            BehaviorMode::Shadow => {
325                // Shadow mode allows everything since it doesn't execute
326            }
327        }
328
329        // Validate guard configurations
330        if let Some(ref atom_guards) = self.guards.atoms {
331            if atom_guards.default_max_bytes == 0 {
332                return Err(anyhow::anyhow!("default_max_bytes cannot be zero"));
333            }
334            if atom_guards.default_max_bytes > 100 * 1024 * 1024 {
335                tracing::warn!(
336                    "Large default_max_bytes ({} bytes) in behavior pack '{}'",
337                    atom_guards.default_max_bytes,
338                    self.name
339                );
340            }
341        }
342
343        if let Some(ref playbook_guards) = self.guards.playbooks {
344            if playbook_guards.max_steps == 0 {
345                return Err(anyhow::anyhow!("max_steps cannot be zero"));
346            }
347            if playbook_guards.max_steps > 100 {
348                tracing::warn!(
349                    "Large max_steps ({}) in behavior pack '{}'",
350                    playbook_guards.max_steps,
351                    self.name
352                );
353            }
354        }
355
356        // Validate parameters are valid JSON objects
357        for (cap_name, params) in &self.params {
358            if !params.is_object() {
359                return Err(anyhow::anyhow!(
360                    "Parameters for capability '{}' must be a JSON object, got: {:?}",
361                    cap_name,
362                    params
363                ));
364            }
365        }
366
367        Ok(())
368    }
369
370    /// Check if a specific atom is enabled in this behavior pack
371    pub fn is_atom_enabled(&self, atom_name: &str) -> bool {
372        self.enable.atoms.contains(&atom_name.to_string())
373    }
374
375    /// Check if a specific macro is enabled in this behavior pack
376    pub fn is_macro_enabled(&self, macro_name: &str) -> bool {
377        self.enable.macros.contains(&macro_name.to_string())
378    }
379
380    /// Check if a specific playbook is enabled in this behavior pack
381    pub fn is_playbook_enabled(&self, playbook_name: &str) -> bool {
382        self.enable.playbooks.contains(&playbook_name.to_string())
383    }
384
385    /// Get parameter overrides for a specific capability
386    pub fn get_params(&self, capability_name: &str) -> Option<&serde_json::Value> {
387        self.params.get(capability_name)
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use tempfile::TempDir;
395
396    #[test]
397    fn test_behavior_pack_validation() {
398        let pack = BehaviorPack {
399            name: "test-pack".to_string(),
400            mode: BehaviorMode::Strict,
401            enable: EnabledCapabilities {
402                atoms: vec![], // Strict mode should have no atoms
403                macros: vec!["test.macro".to_string()],
404                playbooks: vec!["test.playbook".to_string()],
405            },
406            params: HashMap::new(),
407            guards: GuardConfig::default(),
408        };
409
410        assert!(pack.validate().is_ok());
411    }
412
413    #[test]
414    fn test_strict_mode_validation_fails_with_atoms() {
415        let pack = BehaviorPack {
416            name: "test-pack".to_string(),
417            mode: BehaviorMode::Strict,
418            enable: EnabledCapabilities {
419                atoms: vec!["fs.read.v1".to_string()], // Should fail in strict mode
420                macros: vec![],
421                playbooks: vec![],
422            },
423            params: HashMap::new(),
424            guards: GuardConfig::default(),
425        };
426
427        assert!(pack.validate().is_err());
428    }
429
430    #[test]
431    fn test_behavior_pack_manager() -> Result<()> {
432        let temp_dir = TempDir::new()?;
433        let mut manager = BehaviorPackManager::new(temp_dir.path());
434
435        // Create a test behavior pack file
436        let pack_content = r#"
437name: "test-pack"
438mode: strict
439enable:
440  atoms: []
441  macros: ["test.macro"]
442  playbooks: ["test.playbook"]
443params: {}
444guards:
445  atoms:
446    default_max_bytes: 1048576
447    require_justification: true
448"#;
449
450        let pack_path = temp_dir.path().join("test-pack.yaml");
451        std::fs::write(&pack_path, pack_content)?;
452
453        // Load the pack
454        manager.load_all()?;
455
456        // Verify it was loaded
457        assert!(manager.get_pack("test-pack").is_some());
458        assert_eq!(manager.list_packs(), vec!["test-pack"]);
459
460        Ok(())
461    }
462}