1use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::time::{Duration, SystemTime};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14#[serde(rename_all = "snake_case")]
15#[derive(Default)]
16pub enum BehaviorMode {
17 #[default]
19 Strict,
20 Explore,
22 Shadow,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct EnabledCapabilities {
29 pub atoms: Vec<String>,
31 pub macros: Vec<String>,
33 pub playbooks: Vec<String>,
35}
36
37pub type CapabilityParams = HashMap<String, serde_json::Value>;
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct GuardConfig {
43 pub atoms: Option<AtomGuards>,
45 pub macros: Option<MacroGuards>,
47 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#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct AtomGuards {
64 pub default_max_bytes: u64,
66 pub require_justification: bool,
68}
69
70impl Default for AtomGuards {
71 fn default() -> Self {
72 Self {
73 default_max_bytes: 1048576, require_justification: true,
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct MacroGuards {
82 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#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct PlaybookGuards {
97 pub parallel_execution: bool,
99 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#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum ValidationLevel {
116 Strict,
117 Permissive,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct BehaviorPack {
123 pub name: String,
125
126 pub mode: BehaviorMode,
128
129 pub enable: EnabledCapabilities,
131
132 #[serde(default)]
134 pub params: CapabilityParams,
135
136 #[serde(default)]
138 pub guards: GuardConfig,
139}
140
141#[derive(Debug)]
143pub struct BehaviorPackManager {
144 config_dir: PathBuf,
146 packs: HashMap<String, BehaviorPack>,
148 file_times: HashMap<PathBuf, SystemTime>,
150 poll_interval: Duration,
152}
153
154impl BehaviorPackManager {
155 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), }
163 }
164
165 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 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 pack.validate()?;
198
199 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 self.packs.insert(pack.name.clone(), pack);
209
210 tracing::info!("Loaded behavior pack from {}", path.display());
211 Ok(())
212 }
213
214 pub fn get_pack(&self, name: &str) -> Option<&BehaviorPack> {
216 self.packs.get(name)
217 }
218
219 pub fn list_packs(&self) -> Vec<String> {
221 self.packs.keys().cloned().collect()
222 }
223
224 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), };
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, };
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, };
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 }
270 }
271 }
272 }
273 }
274 }
275
276 Ok(reloaded)
277 }
278
279 pub fn poll_interval(&self) -> Duration {
281 self.poll_interval
282 }
283
284 pub fn set_poll_interval(&mut self, interval: Duration) {
286 self.poll_interval = interval;
287 }
288
289 pub fn all_packs(&self) -> &HashMap<String, BehaviorPack> {
291 &self.packs
292 }
293}
294
295impl BehaviorPack {
296 pub fn validate(&self) -> Result<()> {
298 if self.name.is_empty() {
300 return Err(anyhow::anyhow!("Behavior pack name cannot be empty"));
301 }
302
303 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 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 }
327 }
328
329 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 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 pub fn is_atom_enabled(&self, atom_name: &str) -> bool {
372 self.enable.atoms.contains(&atom_name.to_string())
373 }
374
375 pub fn is_macro_enabled(&self, macro_name: &str) -> bool {
377 self.enable.macros.contains(¯o_name.to_string())
378 }
379
380 pub fn is_playbook_enabled(&self, playbook_name: &str) -> bool {
382 self.enable.playbooks.contains(&playbook_name.to_string())
383 }
384
385 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![], 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()], 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 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 manager.load_all()?;
455
456 assert!(manager.get_pack("test-pack").is_some());
458 assert_eq!(manager.list_packs(), vec!["test-pack"]);
459
460 Ok(())
461 }
462}